aicc-py 0.1.0: initial SDK
- protocol.py: pydantic models for all AICC message types (envelope, session_init, tool_call, tool_result, event, error, heartbeat) - bridge.py: Bridge with @bridge.tool registration, capability checks, session management, serve_forever - client.py: AICCClient with single background reader (safe on concurrent transports like WebSocket), call_tool, events, manifest - tool.py: @tool decorator with schema generation from type hints - schema.py: JSON Schema generation (str/int/float/bool, list, dict, Optional, pydantic models) - transport: Transport protocol, InProcessTransport, WebSocket client+server - tests: 12 passing (integration, schema, websocket roundtrip) - examples: bridge_minimal.py + agent_minimal.py (verified end-to-end)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Package marker."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Integration test: end-to-end AICC session via in-process transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from aicc import AICCClient, Bridge, ToolClass
|
||||
from aicc.protocol import ModelClass, TickMode, WorldKind
|
||||
from aicc.transport.in_process import InProcessTransport
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handshake_and_call_tool():
|
||||
bridge = Bridge(
|
||||
name="test-room",
|
||||
kind=WorldKind.TEXT,
|
||||
tick_rate_hz=10.0,
|
||||
tick_mode=TickMode.FIXED,
|
||||
agent_model=ModelClass.EDGE_MEDIUM,
|
||||
)
|
||||
|
||||
@bridge.tool(description="Get the agent's current position and rotation.")
|
||||
async def proprioception() -> dict:
|
||||
return {
|
||||
"position": {"x": 1.0, "y": 0.5, "z": 2.0},
|
||||
"rotation": {"yaw": 0.0, "pitch": 0.0},
|
||||
"velocity": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"health": 100,
|
||||
}
|
||||
|
||||
@bridge.tool(description="Move the agent forward by the given distance.")
|
||||
async def move(forward: float = 0.0) -> dict:
|
||||
return {"moved": forward}
|
||||
|
||||
@bridge.tool(cls=ToolClass.GENERATOR, description="Place an object in the world.")
|
||||
async def place_object(prefab: str) -> dict:
|
||||
return {"spawned": prefab}
|
||||
|
||||
t = InProcessTransport.start(bridge)
|
||||
async with t:
|
||||
client = AICCClient(t)
|
||||
async with client:
|
||||
manifest = await client.handshake()
|
||||
assert manifest.world.name == "test-room"
|
||||
assert manifest.tick_rate_hz == 10.0
|
||||
tool_ids = {t.id for t in manifest.tools}
|
||||
assert {"proprioception", "move", "place_object"} <= tool_ids
|
||||
|
||||
res = await client.call_tool("proprioception", {})
|
||||
assert res.ok
|
||||
assert res.output["position"] == {"x": 1.0, "y": 0.5, "z": 2.0}
|
||||
|
||||
res = await client.call_tool("move", {"forward": 2.5})
|
||||
assert res.ok
|
||||
assert res.output == {"moved": 2.5}
|
||||
|
||||
res = await client.call_tool("place_object", {"prefab": "crate_01"})
|
||||
assert res.ok
|
||||
assert res.output == {"spawned": "crate_01"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_returns_tool_error():
|
||||
bridge = Bridge(name="x")
|
||||
|
||||
@bridge.tool(description="noop")
|
||||
async def ping() -> dict:
|
||||
return {"pong": True}
|
||||
|
||||
t = InProcessTransport.start(bridge)
|
||||
async with t:
|
||||
client = AICCClient(t)
|
||||
async with client:
|
||||
await client.handshake()
|
||||
from aicc.errors import ToolError
|
||||
from aicc.protocol import ErrorCode
|
||||
|
||||
with pytest.raises(ToolError) as ei:
|
||||
await client.call_tool("does_not_exist", {})
|
||||
assert ei.value.code == ErrorCode.TOOL_UNKNOWN
|
||||
assert not ei.value.retryable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protocol_mismatch_raises():
|
||||
from aicc.errors import ProtocolError
|
||||
|
||||
bridge = Bridge(name="x")
|
||||
|
||||
@bridge.tool(description="noop")
|
||||
async def ping() -> dict:
|
||||
return {"pong": True}
|
||||
|
||||
t = InProcessTransport.start(bridge)
|
||||
async with t:
|
||||
client = AICCClient(t, protocol="aicc/0.2")
|
||||
async with client:
|
||||
with pytest.raises(ProtocolError):
|
||||
await client.handshake()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capability_required_tool_is_unavailable():
|
||||
from aicc.errors import ToolError
|
||||
from aicc.protocol import ErrorCode
|
||||
|
||||
bridge = Bridge(name="x")
|
||||
|
||||
@bridge.tool(
|
||||
description="place",
|
||||
cls=ToolClass.GENERATOR,
|
||||
requires_capability="can_modify_world",
|
||||
)
|
||||
async def place_object() -> dict:
|
||||
return {"spawned": True}
|
||||
|
||||
t = InProcessTransport.start(bridge)
|
||||
async with t:
|
||||
client = AICCClient(t)
|
||||
async with client:
|
||||
await client.handshake()
|
||||
with pytest.raises(ToolError) as ei:
|
||||
await client.call_tool("place_object", {})
|
||||
assert ei.value.code == ErrorCode.TOOL_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_failure_is_surfaced():
|
||||
from aicc.errors import ToolError
|
||||
from aicc.protocol import ErrorCode
|
||||
|
||||
bridge = Bridge(name="x")
|
||||
|
||||
@bridge.tool(description="always fails")
|
||||
async def boom() -> dict:
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
t = InProcessTransport.start(bridge)
|
||||
async with t:
|
||||
client = AICCClient(t)
|
||||
async with client:
|
||||
await client.handshake()
|
||||
with pytest.raises(ToolError) as ei:
|
||||
await client.call_tool("boom", {})
|
||||
assert ei.value.code == ErrorCode.EXECUTION_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_generation_basic_types():
|
||||
bridge = Bridge(name="x")
|
||||
|
||||
@bridge.tool(description="basic types")
|
||||
async def fn(a: int, b: str, c: float = 1.0) -> dict:
|
||||
return {"a": a, "b": b, "c": c}
|
||||
|
||||
schema = bridge.tools()[0].input_schema
|
||||
assert schema["properties"]["a"]["type"] == "integer"
|
||||
assert schema["properties"]["b"]["type"] == "string"
|
||||
assert schema["properties"]["c"]["type"] == "number"
|
||||
assert "a" in schema["required"]
|
||||
assert "b" in schema["required"]
|
||||
assert "c" not in schema["required"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Schema unit tests: function_schema and return_schema."""
|
||||
|
||||
from aicc.schema import function_schema, return_schema
|
||||
|
||||
|
||||
def test_basic_types():
|
||||
def fn(a: int, b: str, c: float = 1.0) -> dict: ...
|
||||
|
||||
s = function_schema(fn)
|
||||
assert s["type"] == "object"
|
||||
assert s["properties"]["a"]["type"] == "integer"
|
||||
assert s["properties"]["b"]["type"] == "string"
|
||||
assert s["properties"]["c"]["type"] == "number"
|
||||
assert set(s["required"]) == {"a", "b"}
|
||||
|
||||
|
||||
def test_list_and_dict():
|
||||
def fn(items: list[int], mapping: dict[str, float]) -> dict: ...
|
||||
|
||||
s = function_schema(fn)
|
||||
assert s["properties"]["items"]["type"] == "array"
|
||||
assert s["properties"]["items"]["items"]["type"] == "integer"
|
||||
assert s["properties"]["mapping"]["type"] == "object"
|
||||
|
||||
|
||||
def test_optional():
|
||||
def fn(x: int | None = None) -> dict: ...
|
||||
|
||||
s = function_schema(fn)
|
||||
assert s["properties"]["x"]["nullable"] is True
|
||||
assert "x" not in s["required"]
|
||||
|
||||
|
||||
def test_pydantic_model_as_root_input():
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Input(BaseModel):
|
||||
x: int
|
||||
y: str
|
||||
|
||||
def fn(data: Input) -> dict: ...
|
||||
|
||||
s = function_schema(fn)
|
||||
assert "properties" in s
|
||||
assert "x" in s["properties"]
|
||||
assert "y" in s["properties"]
|
||||
|
||||
|
||||
def test_return_schema_basic():
|
||||
def fn() -> int: ...
|
||||
assert return_schema(fn)["type"] == "integer"
|
||||
|
||||
def g() -> list[str]: ...
|
||||
assert return_schema(g)["type"] == "array"
|
||||
|
||||
def h() -> dict: ...
|
||||
assert return_schema(h)["type"] == "object"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""End-to-end test over WebSocket: server + client in the same event loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from aicc import AICCClient, Bridge
|
||||
from aicc.transport import WebSocketClientTransport, WebSocketServer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_roundtrip():
|
||||
bridge = Bridge(name="ws-room")
|
||||
|
||||
@bridge.tool(description="Echo a value.")
|
||||
async def echo(value: str = "") -> dict:
|
||||
return {"value": value}
|
||||
|
||||
async with WebSocketServer(bridge, port=0) as server:
|
||||
# port=0 -> OS-assigned; read it back from the server object
|
||||
port = server._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
|
||||
uri = f"ws://127.0.0.1:{port}"
|
||||
|
||||
async with AICCClient(WebSocketClientTransport(uri)) as client:
|
||||
manifest = await client.handshake()
|
||||
assert manifest.world.name == "ws-room"
|
||||
|
||||
res = await client.call_tool("echo", {"value": "hi"})
|
||||
assert res.ok
|
||||
assert res.output == {"value": "hi"}
|
||||
Reference in New Issue
Block a user