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:
+19
@@ -0,0 +1,19 @@
|
||||
# Build / editor artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Emil Shanaty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,112 @@
|
||||
# aicc-py
|
||||
|
||||
Python SDK for the [AICC Protocol](https://github.com/emil28092005/AICC-Protocol) (AI-Controlled Character).
|
||||
|
||||
`aicc-py` provides client and bridge primitives for connecting language-model agents to virtual environments via the AICC wire protocol. Engine-agnostic, transport-pluggable, async-first.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` — matches AICC protocol `aicc/0.1`. Alpha.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install aicc
|
||||
```
|
||||
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/emil28092005/aicc-py
|
||||
cd aicc-py
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Minimal example
|
||||
|
||||
### Bridge side
|
||||
|
||||
```python
|
||||
from aicc import Bridge
|
||||
|
||||
bridge = Bridge(name="capsule-room", kind="3d")
|
||||
|
||||
@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 in meters.")
|
||||
async def move(forward: float = 0.0) -> dict:
|
||||
# your physics / path-planning code here
|
||||
return {"moved": forward}
|
||||
|
||||
from aicc.transport import WebSocketServer
|
||||
async with WebSocketServer(bridge, port=8765):
|
||||
await bridge.serve_forever()
|
||||
```
|
||||
|
||||
### Client side
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from aicc import AICCClient
|
||||
from aicc.transport import WebSocketClientTransport
|
||||
|
||||
async def main():
|
||||
async with AICCClient(WebSocketClientTransport("ws://localhost:8765")) as client:
|
||||
manifest = await client.handshake()
|
||||
result = await client.call_tool("proprioception", {})
|
||||
print(result.output)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### In-process (for tests and embedding)
|
||||
|
||||
```python
|
||||
from aicc import Bridge, AICCClient
|
||||
from aicc.transport import InProcessTransport
|
||||
|
||||
bridge = Bridge(name="test")
|
||||
@bridge.tool(description="noop")
|
||||
async def ping() -> dict:
|
||||
return {"pong": True}
|
||||
|
||||
async with InProcessTransport(bridge) as transport:
|
||||
client = AICCClient(transport)
|
||||
manifest = await client.handshake()
|
||||
result = await client.call_tool("ping", {})
|
||||
assert result.output == {"pong": True}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
aicc/
|
||||
protocol.py # message types, envelope, errors (pydantic models)
|
||||
client.py # AICCClient — agent side
|
||||
bridge.py # Bridge — environment side, tool registration
|
||||
tool.py # @tool decorator and tool metadata
|
||||
schema.py # JSON Schema generation utilities
|
||||
transport/
|
||||
base.py # Transport interface
|
||||
in_process.py # In-process transport (tests, embedded)
|
||||
websocket.py # WebSocket transport (client + server)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
ruff check .
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE`.
|
||||
@@ -0,0 +1,45 @@
|
||||
"""aicc-py: Python SDK for the AI-Controlled Character Protocol."""
|
||||
|
||||
from aicc.protocol import (
|
||||
ErrorCode,
|
||||
MessageType,
|
||||
TickMode,
|
||||
WorldKind,
|
||||
ModelClass,
|
||||
ToolClass,
|
||||
SessionInit,
|
||||
SessionClose,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
EventMessage,
|
||||
ErrorMessage,
|
||||
Heartbeat,
|
||||
)
|
||||
from aicc.errors import AICCError, ToolError, CapabilityError, ProtocolError
|
||||
from aicc.bridge import Bridge
|
||||
from aicc.client import AICCClient
|
||||
from aicc.tool import tool
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"Bridge",
|
||||
"AICCClient",
|
||||
"tool",
|
||||
"ErrorCode",
|
||||
"MessageType",
|
||||
"TickMode",
|
||||
"WorldKind",
|
||||
"ModelClass",
|
||||
"ToolClass",
|
||||
"SessionInit",
|
||||
"SessionClose",
|
||||
"ToolCall",
|
||||
"ToolResult",
|
||||
"EventMessage",
|
||||
"ErrorMessage",
|
||||
"Heartbeat",
|
||||
"AICCError",
|
||||
"ToolError",
|
||||
"CapabilityError",
|
||||
"ProtocolError",
|
||||
]
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
"""Bridge: environment side of the AICC protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from aicc.protocol import (
|
||||
Capabilities,
|
||||
ErrorCode,
|
||||
ErrorPayload,
|
||||
Heartbeat,
|
||||
MessageType,
|
||||
ModelClass,
|
||||
ModelDecl,
|
||||
SessionClose,
|
||||
SessionInit,
|
||||
TickMode,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolSpec,
|
||||
WorldKind,
|
||||
WorldSpec,
|
||||
PROTOCOL_VERSION,
|
||||
)
|
||||
from aicc.tool import ToolDefinition, call_tool_impl
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A live bridge session with one connected agent."""
|
||||
|
||||
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
manifest: SessionInit | None = None
|
||||
send_queue: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue)
|
||||
closed: bool = False
|
||||
|
||||
|
||||
class Bridge:
|
||||
"""Environment-side bridge.
|
||||
|
||||
Holds the world state, registered tools, and per-session state.
|
||||
Bridges run inside transports (in-process, websocket, etc.) and do not
|
||||
perform I/O directly.
|
||||
|
||||
Example:
|
||||
bridge = Bridge(name="capsule", kind=WorldKind.THREE_D)
|
||||
|
||||
@bridge.tool(description="Get current position.")
|
||||
async def proprioception() -> dict:
|
||||
return {"position": {"x": 0, "y": 0, "z": 0}, ...}
|
||||
|
||||
bridge.register_tool(...)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
kind: WorldKind = WorldKind.THREE_D,
|
||||
tick_rate_hz: float = 10.0,
|
||||
tick_mode: TickMode = TickMode.FIXED,
|
||||
agent_model: ModelClass = ModelClass.EDGE_MEDIUM,
|
||||
expected_first_token_ms: int | None = None,
|
||||
expected_full_response_ms: int | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.tick_rate_hz = tick_rate_hz
|
||||
self.tick_mode = tick_mode
|
||||
self.agent_model = agent_model
|
||||
self.expected_first_token_ms = expected_first_token_ms
|
||||
self.expected_full_response_ms = expected_full_response_ms
|
||||
|
||||
self._tools: dict[str, ToolDefinition] = {}
|
||||
self._sessions: dict[str, Session] = {}
|
||||
|
||||
# ---------- Tool registration ----------
|
||||
|
||||
def tool(
|
||||
self,
|
||||
_fn: Callable[..., Any] | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
cls=None,
|
||||
description: str | None = None,
|
||||
requires_capability: str | None = None,
|
||||
limits: dict[str, Any] | None = None,
|
||||
strict_input: bool = False,
|
||||
):
|
||||
"""Register a tool. Use as @bridge.tool or @bridge.tool(cls=..., ...)."""
|
||||
from aicc.tool import tool as _tool
|
||||
|
||||
if _fn is not None and callable(_fn):
|
||||
defn = _tool(_fn)
|
||||
self.register_tool(defn)
|
||||
return _fn
|
||||
|
||||
def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
defn = _tool(
|
||||
fn,
|
||||
id=id,
|
||||
cls=cls,
|
||||
description=description,
|
||||
requires_capability=requires_capability,
|
||||
limits=limits,
|
||||
strict_input=strict_input,
|
||||
)
|
||||
self.register_tool(defn)
|
||||
return fn
|
||||
|
||||
return wrap
|
||||
|
||||
def register_tool(self, definition: ToolDefinition) -> None:
|
||||
if definition.id in self._tools:
|
||||
raise ValueError(f"Tool id '{definition.id}' is already registered.")
|
||||
self._tools[definition.id] = definition
|
||||
|
||||
def tools(self) -> list[ToolDefinition]:
|
||||
return list(self._tools.values())
|
||||
|
||||
# ---------- Capability computation ----------
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(
|
||||
sensors=[t.id for t in self._tools.values() if t.cls.value == "sensor"],
|
||||
actuators=[t.id for t in self._tools.values() if t.cls.value == "actuator"],
|
||||
generators=[t.id for t in self._tools.values() if t.cls.value == "generator"],
|
||||
)
|
||||
|
||||
# ---------- Session handling ----------
|
||||
|
||||
def open_session(self) -> Session:
|
||||
session = Session()
|
||||
manifest = self._build_manifest(session.session_id)
|
||||
session.manifest = manifest
|
||||
self._sessions[session.session_id] = session
|
||||
return session
|
||||
|
||||
def close_session(self, session_id: str) -> None:
|
||||
sess = self._sessions.pop(session_id, None)
|
||||
if sess is not None:
|
||||
sess.closed = True
|
||||
|
||||
def session(self, session_id: str) -> Session:
|
||||
sess = self._sessions.get(session_id)
|
||||
if sess is None or sess.closed:
|
||||
raise KeyError(session_id)
|
||||
return sess
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
"""Run the bridge event loop until cancelled.
|
||||
|
||||
Useful with a server transport (e.g. WebSocketServer) already running:
|
||||
async with WebSocketServer(bridge, port=8765):
|
||||
await bridge.serve_forever()
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
def _build_manifest(self, session_id: str) -> SessionInit:
|
||||
return SessionInit(
|
||||
session_id=session_id,
|
||||
tick_rate_hz=self.tick_rate_hz,
|
||||
tick_mode=self.tick_mode,
|
||||
world=WorldSpec(name=self.name, kind=self.kind),
|
||||
agent_model=ModelDecl(
|
||||
class_=self.agent_model,
|
||||
expected_first_token_ms=self.expected_first_token_ms,
|
||||
expected_full_response_ms=self.expected_full_response_ms,
|
||||
),
|
||||
capabilities=self.capabilities(),
|
||||
tools=[
|
||||
ToolSpec(
|
||||
id=t.id,
|
||||
class_=t.cls,
|
||||
description=t.description,
|
||||
input_schema=t.input_schema,
|
||||
output_schema=t.output_schema,
|
||||
requires_capability=t.requires_capability,
|
||||
limits=t.limits,
|
||||
strict_input=t.strict_input,
|
||||
)
|
||||
for t in self._tools.values()
|
||||
],
|
||||
)
|
||||
|
||||
# ---------- Message handling ----------
|
||||
|
||||
async def handle_message(self, session_id: str, raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Handle a single incoming message from an agent.
|
||||
|
||||
Returns the response message (or None for fire-and-forget events/heartbeats).
|
||||
May also push async events to the session queue.
|
||||
"""
|
||||
msg_type = raw.get("type")
|
||||
if msg_type == MessageType.MANIFEST_REQUEST.value:
|
||||
sess = self.session(session_id)
|
||||
return sess.manifest.model_dump(by_alias=True) if sess.manifest else None
|
||||
if msg_type == MessageType.TOOL_CALL.value:
|
||||
return await self._handle_tool_call(session_id, ToolCall.model_validate(raw))
|
||||
if msg_type == MessageType.SESSION_CLOSE.value:
|
||||
self.close_session(session_id)
|
||||
return SessionClose(
|
||||
session_id=session_id,
|
||||
reason=(raw.get("reason") or "client_close"),
|
||||
).model_dump(by_alias=True)
|
||||
if msg_type == MessageType.HEARTBEAT.value:
|
||||
return Heartbeat(session_id=session_id).model_dump(by_alias=True)
|
||||
if msg_type == MessageType.SESSION_RESUME.value:
|
||||
sess = self.session(raw.get("session_id") or session_id)
|
||||
return sess.manifest.model_dump(by_alias=True) if sess.manifest else None
|
||||
return None
|
||||
|
||||
async def _handle_tool_call(self, session_id: str, call: ToolCall) -> dict[str, Any]:
|
||||
defn = self._tools.get(call.tool)
|
||||
if defn is None:
|
||||
return ToolResult(
|
||||
session_id=session_id,
|
||||
call_id=call.call_id,
|
||||
ok=False,
|
||||
error=ErrorPayload(
|
||||
code=ErrorCode.TOOL_UNKNOWN,
|
||||
message=f"Tool '{call.tool}' is not registered.",
|
||||
retryable=False,
|
||||
),
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
if defn.requires_capability and defn.requires_capability not in self._sessions[session_id].manifest.capabilities.sensors + self._sessions[session_id].manifest.capabilities.actuators + self._sessions[session_id].manifest.capabilities.generators: # type: ignore[union-attr]
|
||||
return ToolResult(
|
||||
session_id=session_id,
|
||||
call_id=call.call_id,
|
||||
ok=False,
|
||||
error=ErrorPayload(
|
||||
code=ErrorCode.TOOL_UNAVAILABLE,
|
||||
message=f"Tool '{call.tool}' requires capability '{defn.requires_capability}'.",
|
||||
retryable=False,
|
||||
),
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
try:
|
||||
output = await call_tool_impl(defn, call.input)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(
|
||||
session_id=session_id,
|
||||
call_id=call.call_id,
|
||||
ok=False,
|
||||
error=ErrorPayload(
|
||||
code=ErrorCode.EXECUTION_FAILED,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
retryable=False,
|
||||
),
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
if not isinstance(output, dict):
|
||||
output = {"value": output}
|
||||
|
||||
return ToolResult(
|
||||
session_id=session_id,
|
||||
call_id=call.call_id,
|
||||
ok=True,
|
||||
output=output,
|
||||
).model_dump(by_alias=True)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"""AICCClient: agent side of the AICC protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from aicc.protocol import (
|
||||
PROTOCOL_VERSION,
|
||||
ErrorCode,
|
||||
EventMessage,
|
||||
MessageType,
|
||||
SessionClose,
|
||||
SessionInit,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from aicc.errors import (
|
||||
AICCError,
|
||||
ConnectionError_,
|
||||
ProtocolError,
|
||||
ToolError,
|
||||
)
|
||||
from aicc.transport.base import Transport
|
||||
|
||||
|
||||
class AICCClient:
|
||||
"""Agent-side AICC client.
|
||||
|
||||
Wraps a Transport and exposes high-level operations:
|
||||
- handshake() -> manifest
|
||||
- call_tool(name, input) -> ToolResult
|
||||
- next_event() -> EventMessage
|
||||
- close
|
||||
|
||||
A single background reader task consumes the transport and routes
|
||||
messages into an internal queue, so any number of concurrent callers
|
||||
(tool calls, event consumers) are safe on any transport, including
|
||||
WebSocket where concurrent recv() is forbidden.
|
||||
|
||||
Example:
|
||||
client = AICCClient(WebSocketClientTransport("ws://localhost:8765"))
|
||||
async with client:
|
||||
manifest = await client.handshake()
|
||||
result = await client.call_tool("vision", {})
|
||||
"""
|
||||
|
||||
def __init__(self, transport: Transport, protocol: str = PROTOCOL_VERSION):
|
||||
self._transport = transport
|
||||
self._protocol = protocol
|
||||
self._session_id: str | None = None
|
||||
self._manifest: SessionInit | None = None
|
||||
self._inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
self._reader_task: asyncio.Task | None = None
|
||||
|
||||
# ---------- Lifecycle ----------
|
||||
|
||||
async def __aenter__(self) -> "AICCClient":
|
||||
await self._transport.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
await self.close()
|
||||
|
||||
async def connect(self) -> "AICCClient":
|
||||
await self._transport.connect()
|
||||
return self
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._reader_task is not None:
|
||||
self._reader_task.cancel()
|
||||
try:
|
||||
await self._reader_task
|
||||
except (asyncio.CancelledError, AICCError):
|
||||
pass
|
||||
self._reader_task = None
|
||||
if self._session_id is not None:
|
||||
try:
|
||||
await self._transport.send(
|
||||
SessionClose(
|
||||
session_id=self._session_id,
|
||||
reason="client_close",
|
||||
).model_dump(by_alias=True)
|
||||
)
|
||||
except AICCError:
|
||||
pass
|
||||
self._session_id = None
|
||||
await self._transport.close()
|
||||
|
||||
# ---------- Manifest ----------
|
||||
|
||||
async def handshake(self) -> SessionInit:
|
||||
"""Wait for the bridge to push the initial session_init manifest."""
|
||||
self._spawn_reader()
|
||||
init = await self._receive_until(SessionInit)
|
||||
self._session_id = init.session_id
|
||||
self._manifest = init
|
||||
return init
|
||||
|
||||
async def fetch_manifest(self) -> SessionInit:
|
||||
if self._session_id is None:
|
||||
raise ProtocolError("handshake() must be called before fetch_manifest().")
|
||||
await self._transport.send(
|
||||
{
|
||||
"type": MessageType.MANIFEST_REQUEST.value,
|
||||
"session_id": self._session_id,
|
||||
"message_id": str(uuid.uuid4()),
|
||||
}
|
||||
)
|
||||
return await self._receive_until(SessionInit)
|
||||
|
||||
# ---------- Tool calls ----------
|
||||
|
||||
async def call_tool(self, tool_id: str, input: dict[str, Any]) -> ToolResult:
|
||||
if self._session_id is None:
|
||||
raise ProtocolError("handshake() must be called before call_tool().")
|
||||
call_id = f"tc_{uuid.uuid4().hex[:12]}"
|
||||
await self._transport.send(
|
||||
ToolCall(
|
||||
session_id=self._session_id,
|
||||
call_id=call_id,
|
||||
tool=tool_id,
|
||||
input=input,
|
||||
).model_dump(by_alias=True)
|
||||
)
|
||||
|
||||
while True:
|
||||
msg = await self._inbox.get()
|
||||
self._validate_protocol(msg)
|
||||
t = msg.get("type")
|
||||
if t == MessageType.ERROR.value:
|
||||
raise ProtocolError(f"bridge error: {msg.get('error')}")
|
||||
if t != MessageType.TOOL_RESULT.value:
|
||||
continue
|
||||
result = ToolResult.model_validate(msg)
|
||||
if result.call_id != call_id:
|
||||
continue
|
||||
if not result.ok and result.error:
|
||||
raise ToolError(
|
||||
code=result.error.code,
|
||||
message=result.error.message,
|
||||
retryable=result.error.retryable,
|
||||
)
|
||||
return result
|
||||
|
||||
# ---------- Events ----------
|
||||
|
||||
async def next_event(self, timeout: float | None = None) -> EventMessage:
|
||||
if timeout is None:
|
||||
raw = await self._inbox.get()
|
||||
else:
|
||||
raw = await asyncio.wait_for(self._inbox.get(), timeout=timeout)
|
||||
return EventMessage.model_validate(raw)
|
||||
|
||||
def _spawn_reader(self) -> None:
|
||||
async def read_loop() -> None:
|
||||
try:
|
||||
while True:
|
||||
msg = await self._transport.receive()
|
||||
await self._inbox.put(msg)
|
||||
except (ConnectionError_, asyncio.CancelledError):
|
||||
return
|
||||
|
||||
self._reader_task = asyncio.create_task(read_loop())
|
||||
|
||||
# ---------- Internals ----------
|
||||
|
||||
async def _receive_until(self, expect_cls: type) -> SessionInit:
|
||||
while True:
|
||||
msg = await self._inbox.get()
|
||||
self._validate_protocol(msg)
|
||||
t = msg.get("type")
|
||||
if t == MessageType.ERROR.value:
|
||||
raise ProtocolError(f"bridge error: {msg.get('error')}")
|
||||
if t == expect_cls.model_fields["type"].default:
|
||||
return expect_cls.model_validate(msg)
|
||||
|
||||
def _validate_protocol(self, msg: dict[str, Any]) -> None:
|
||||
proto = msg.get("protocol")
|
||||
if proto != self._protocol:
|
||||
raise ProtocolError(
|
||||
f"Protocol mismatch: client='{self._protocol}', message='{proto}'"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""AICC exception hierarchy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aicc.protocol import ErrorCode
|
||||
|
||||
|
||||
class AICCError(Exception):
|
||||
"""Base for all AICC SDK errors."""
|
||||
|
||||
|
||||
class ProtocolError(AICCError):
|
||||
"""Protocol-level error (version mismatch, malformed message)."""
|
||||
|
||||
|
||||
class ConnectionError_(AICCError):
|
||||
"""Transport / session connection failure."""
|
||||
|
||||
|
||||
class SessionExpiredError(AICCError):
|
||||
"""Session id is unknown or closed."""
|
||||
|
||||
|
||||
class ToolError(AICCError):
|
||||
"""Tool execution failure surfaced by the bridge."""
|
||||
|
||||
def __init__(self, code: ErrorCode, message: str, retryable: bool):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
class CapabilityError(AICCError):
|
||||
"""Tool requires a capability the agent does not hold."""
|
||||
@@ -0,0 +1,193 @@
|
||||
"""AICC protocol message types and enums."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
PROTOCOL_VERSION = "aicc/0.1"
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
SESSION_INIT = "session_init"
|
||||
SESSION_RESUME = "session_resume"
|
||||
SESSION_CLOSE = "session_close"
|
||||
MANIFEST_REQUEST = "manifest_request"
|
||||
TOOL_CALL = "tool_call"
|
||||
TOOL_RESULT = "tool_result"
|
||||
EVENT = "event"
|
||||
ERROR = "error"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
|
||||
class TickMode(str, Enum):
|
||||
FIXED = "fixed"
|
||||
EVENT = "event"
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class WorldKind(str, Enum):
|
||||
TWO_D = "2d"
|
||||
THREE_D = "3d"
|
||||
TEXT = "text"
|
||||
ABSTRACT = "abstract"
|
||||
|
||||
|
||||
class ModelClass(str, Enum):
|
||||
EDGE_SMALL = "edge_small"
|
||||
EDGE_MEDIUM = "edge_medium"
|
||||
CLOUD_MEDIUM = "cloud_medium"
|
||||
CLOUD_LARGE = "cloud_large"
|
||||
|
||||
|
||||
class ToolClass(str, Enum):
|
||||
SENSOR = "sensor"
|
||||
ACTUATOR = "actuator"
|
||||
GENERATOR = "generator"
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
PROTOCOL_MISMATCH = "protocol_mismatch"
|
||||
SESSION_EXPIRED = "session_expired"
|
||||
TOOL_UNKNOWN = "tool_unknown"
|
||||
TOOL_UNAVAILABLE = "tool_unavailable"
|
||||
INVALID_INPUT = "invalid_input"
|
||||
EXECUTION_FAILED = "execution_failed"
|
||||
TIMEOUT = "timeout"
|
||||
INTERNAL_ERROR = "internal_error"
|
||||
|
||||
|
||||
class EventTopic(str, Enum):
|
||||
TICK = "tick"
|
||||
COLLISION = "collision"
|
||||
AUDIO = "audio"
|
||||
STATE_CHANGE = "state_change"
|
||||
AGENT_MESSAGE = "agent_message"
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Envelope(BaseModel):
|
||||
"""Common envelope for all AICC messages."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
protocol: Literal["aicc/0.1"] = PROTOCOL_VERSION
|
||||
session_id: str = Field(default_factory=_uuid)
|
||||
message_id: str = Field(default_factory=_uuid)
|
||||
|
||||
|
||||
class WorldSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
kind: WorldKind
|
||||
|
||||
|
||||
class ModelDecl(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
class_: ModelClass = Field(alias="class")
|
||||
expected_first_token_ms: int | None = None
|
||||
expected_full_response_ms: int | None = None
|
||||
|
||||
|
||||
class ToolLimits(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calls_per_minute: int | None = None
|
||||
calls_per_session: int | None = None
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
"""Tool definition advertised in the session manifest."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
id: str = Field(pattern=r"^[a-z][a-z0-9_]*$")
|
||||
class_: ToolClass = Field(alias="class")
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
requires_capability: str | None = None
|
||||
limits: ToolLimits | None = None
|
||||
strict_input: bool = False
|
||||
|
||||
|
||||
class Capabilities(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sensors: list[str] = Field(default_factory=list)
|
||||
actuators: list[str] = Field(default_factory=list)
|
||||
generators: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionInit(Envelope):
|
||||
type: Literal[MessageType.SESSION_INIT] = MessageType.SESSION_INIT
|
||||
tick_rate_hz: float = Field(gt=0)
|
||||
tick_mode: TickMode
|
||||
world: WorldSpec
|
||||
agent_model: ModelDecl
|
||||
capabilities: Capabilities
|
||||
tools: list[ToolSpec]
|
||||
event_subscriptions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionClose(Envelope):
|
||||
type: Literal[MessageType.SESSION_CLOSE] = MessageType.SESSION_CLOSE
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ToolCall(Envelope):
|
||||
type: Literal[MessageType.TOOL_CALL] = MessageType.TOOL_CALL
|
||||
call_id: str
|
||||
tool: str = Field(pattern=r"^[a-z][a-z0-9_]*$")
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Meta(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
tick: int | None = None
|
||||
latency_ms: int | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class ErrorPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
code: ErrorCode
|
||||
message: str
|
||||
retryable: bool
|
||||
|
||||
|
||||
class ToolResult(Envelope):
|
||||
type: Literal[MessageType.TOOL_RESULT] = MessageType.TOOL_RESULT
|
||||
call_id: str
|
||||
ok: bool
|
||||
output: dict[str, Any] | None = None
|
||||
error: ErrorPayload | None = None
|
||||
meta: Meta | None = None
|
||||
|
||||
|
||||
class EventMessage(Envelope):
|
||||
type: Literal[MessageType.EVENT] = MessageType.EVENT
|
||||
topic: EventTopic | str
|
||||
payload: dict[str, Any]
|
||||
meta: Meta | None = None
|
||||
|
||||
|
||||
class ErrorMessage(Envelope):
|
||||
type: Literal[MessageType.ERROR] = MessageType.ERROR
|
||||
error: ErrorPayload
|
||||
|
||||
|
||||
class Heartbeat(Envelope):
|
||||
type: Literal[MessageType.HEARTBEAT] = MessageType.HEARTBEAT
|
||||
manifest_update: dict[str, Any] | None = None
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""JSON Schema generation utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable, get_args, get_origin, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def function_schema(fn: Callable[..., Any]) -> dict[str, Any]:
|
||||
"""Generate a JSON Schema for a function's input from its signature.
|
||||
|
||||
Supports:
|
||||
- Plain Python type hints (str, int, float, bool, list, dict, etc.)
|
||||
- Pydantic BaseModel subclasses as root input
|
||||
- Optional[X] / Union[X, None]
|
||||
- Default values from the signature
|
||||
|
||||
Returns a JSON Schema dict with type=object, properties, and required.
|
||||
"""
|
||||
sig = inspect.signature(fn)
|
||||
hints = get_type_hints(fn)
|
||||
|
||||
# If the return / first param is a Pydantic model, use its schema as root.
|
||||
for param_name, param in sig.parameters.items():
|
||||
ann = hints.get(param_name, param.annotation)
|
||||
if ann is inspect.Parameter.empty:
|
||||
continue
|
||||
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
||||
schema = ann.model_json_schema()
|
||||
schema.pop("title", None)
|
||||
return _strip_unsupported(schema)
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == "self":
|
||||
continue
|
||||
ann = hints.get(param_name, param.annotation)
|
||||
if ann is inspect.Parameter.empty:
|
||||
continue
|
||||
properties[param_name] = _annotation_to_schema(ann)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
required.append(param_name)
|
||||
|
||||
return {"type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||
|
||||
|
||||
def return_schema(fn: Callable[..., Any]) -> dict[str, Any]:
|
||||
"""Generate JSON Schema for a function's return type."""
|
||||
hints = get_type_hints(fn)
|
||||
ret = hints.get("return", inspect.signature(fn).return_annotation)
|
||||
if ret is inspect.Signature.empty:
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
if isinstance(ret, type) and issubclass(ret, BaseModel):
|
||||
schema = ret.model_json_schema()
|
||||
schema.pop("title", None)
|
||||
return _strip_unsupported(schema)
|
||||
return _annotation_to_schema(ret)
|
||||
|
||||
|
||||
def _annotation_to_schema(ann: Any) -> dict[str, Any]:
|
||||
origin = get_origin(ann)
|
||||
args = get_args(ann)
|
||||
|
||||
# Plain types
|
||||
if ann is str:
|
||||
return {"type": "string"}
|
||||
if ann is int:
|
||||
return {"type": "integer"}
|
||||
if ann is float:
|
||||
return {"type": "number"}
|
||||
if ann is bool:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# Containers — check BEFORE generic Union handling, since
|
||||
# dict[str, X] / list[X] carry args too.
|
||||
if origin is dict or ann is dict:
|
||||
if args and len(args) == 2:
|
||||
return {
|
||||
"type": "object",
|
||||
"additionalProperties": _annotation_to_schema(args[1]),
|
||||
}
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
if origin is list or ann is list:
|
||||
if args and len(args) == 1:
|
||||
return {"type": "array", "items": _annotation_to_schema(args[0])}
|
||||
return {"type": "array"}
|
||||
|
||||
# Optional[X] / Union[X, None] / Union[X, Y]
|
||||
if origin is not None and args:
|
||||
non_none = [a for a in args if a is not type(None)]
|
||||
if len(non_none) == 1 and len(args) > len(non_none):
|
||||
sub = _annotation_to_schema(non_none[0])
|
||||
sub["nullable"] = True
|
||||
return sub
|
||||
if len(non_none) > 1:
|
||||
return {"anyOf": [_annotation_to_schema(a) for a in non_none]}
|
||||
|
||||
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
||||
schema = ann.model_json_schema()
|
||||
schema.pop("title", None)
|
||||
return _strip_unsupported(schema)
|
||||
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
|
||||
|
||||
def _strip_unsupported(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove JSON Schema keys that some LLM tool-use pipelines reject."""
|
||||
schema.pop("title", None)
|
||||
return schema
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tool registration and the @tool decorator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from aicc.protocol import ToolClass
|
||||
from aicc.schema import function_schema, return_schema
|
||||
|
||||
|
||||
ToolImpl = Callable[..., Any | Awaitable[Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDefinition:
|
||||
id: str
|
||||
cls: ToolClass
|
||||
description: str
|
||||
fn: ToolImpl
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
requires_capability: str | None = None
|
||||
limits: dict[str, Any] | None = None
|
||||
strict_input: bool = False
|
||||
|
||||
|
||||
def tool(
|
||||
_fn: ToolImpl | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
cls: ToolClass | None = None,
|
||||
description: str | None = None,
|
||||
requires_capability: str | None = None,
|
||||
limits: dict[str, Any] | None = None,
|
||||
strict_input: bool = False,
|
||||
) -> Any:
|
||||
"""Mark a function as an AICC tool.
|
||||
|
||||
Use as @tool or @tool(cls=ToolClass.SENSOR, description=...).
|
||||
"""
|
||||
|
||||
def wrap(fn: ToolImpl) -> ToolDefinition:
|
||||
tool_id = id or fn.__name__
|
||||
if not tool_id.replace("_", "").isalnum() or tool_id[0].isdigit():
|
||||
raise ValueError(
|
||||
f"Tool id '{tool_id}' must match ^[a-z][a-z0-9_]*$ "
|
||||
"(lowercase letters, digits, underscores; cannot start with digit)."
|
||||
)
|
||||
tool_cls = cls or _infer_class(fn)
|
||||
if description:
|
||||
desc = description
|
||||
else:
|
||||
doc = inspect.getdoc(fn) or ""
|
||||
desc = doc.strip().splitlines()[0] if doc else ""
|
||||
if not desc:
|
||||
raise ValueError(
|
||||
f"Tool '{tool_id}' requires a description (set description= or add a docstring)."
|
||||
)
|
||||
return ToolDefinition(
|
||||
id=tool_id,
|
||||
cls=tool_cls,
|
||||
description=desc,
|
||||
fn=fn,
|
||||
input_schema=function_schema(fn),
|
||||
output_schema=return_schema(fn),
|
||||
requires_capability=requires_capability,
|
||||
limits=limits,
|
||||
strict_input=strict_input,
|
||||
)
|
||||
|
||||
if _fn is not None and callable(_fn):
|
||||
return wrap(_fn)
|
||||
return wrap
|
||||
|
||||
|
||||
def _infer_class(fn: ToolImpl) -> ToolClass:
|
||||
"""Default to sensor if the tool starts with get_/read_/observe_; otherwise actuator."""
|
||||
name = fn.__name__.lower()
|
||||
if any(name.startswith(p) for p in ("get_", "read_", "observe_", "inspect_", "see_", "hear_", "smell_", "touch_")):
|
||||
return ToolClass.SENSOR
|
||||
return ToolClass.ACTUATOR
|
||||
|
||||
|
||||
async def call_tool_impl(defn: ToolDefinition, input_data: dict[str, Any]) -> Any:
|
||||
"""Invoke a registered tool's implementation with the given input."""
|
||||
if defn.strict_input:
|
||||
from jsonschema import validate as _validate # type: ignore
|
||||
|
||||
_validate(instance=input_data, schema=defn.input_schema)
|
||||
result = defn.fn(**input_data) if input_data else defn.fn()
|
||||
if asyncio.iscoroutine(result) or inspect.isawaitable(result):
|
||||
result = await result # type: ignore[func-returns-value]
|
||||
return result
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Transport implementations for AICC."""
|
||||
|
||||
from aicc.transport.base import Transport
|
||||
from aicc.transport.in_process import InProcessTransport
|
||||
|
||||
__all__ = ["Transport", "InProcessTransport"]
|
||||
|
||||
try: # pragma: no cover
|
||||
from aicc.transport.websocket import WebSocketClientTransport, WebSocketServer
|
||||
|
||||
__all__ += ["WebSocketClientTransport", "WebSocketServer"]
|
||||
except ImportError: # websockets not installed
|
||||
pass
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Transport interface for AICC message passing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""Pluggable transport for AICC messages.
|
||||
|
||||
Implementations:
|
||||
- aicc.transport.in_process.InProcessTransport
|
||||
- aicc.transport.websocket.WebSocketClientTransport / WebSocketServer
|
||||
"""
|
||||
|
||||
async def connect(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
async def send(self, message: dict[str, Any]) -> None: ...
|
||||
async def receive(self) -> dict[str, Any]: ...
|
||||
@@ -0,0 +1,87 @@
|
||||
"""In-process transport: bridges AICC clients to a Bridge within one event loop.
|
||||
|
||||
Useful for tests, embedded use, and single-process agents that drive the bridge directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from aicc.bridge import Bridge
|
||||
from aicc.errors import ConnectionError_
|
||||
from aicc.protocol import PROTOCOL_VERSION, SessionInit
|
||||
|
||||
|
||||
class InProcessTransport:
|
||||
"""Two-sided transport: one side is the bridge, the other is the client.
|
||||
|
||||
Both sides share the same asyncio event loop. The bridge side pushes its
|
||||
manifest into the client's receive queue when start() is awaited.
|
||||
|
||||
Example:
|
||||
async with InProcessTransport.start(bridge) as t:
|
||||
client = AICCClient(t)
|
||||
async with client:
|
||||
manifest = await client.handshake()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._send_q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
self._recv_q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
self._bridge: Bridge | None = None
|
||||
self._session_id: str | None = None
|
||||
self._connected = False
|
||||
|
||||
@classmethod
|
||||
def start(cls, bridge: Bridge) -> "InProcessTransport":
|
||||
t = cls()
|
||||
t._bridge = bridge
|
||||
return t
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._bridge is None:
|
||||
raise ConnectionError_("InProcessTransport.start(bridge) must be used for server-side.")
|
||||
if self._connected:
|
||||
return
|
||||
self._connected = True
|
||||
session = self._bridge.open_session()
|
||||
self._session_id = session.session_id
|
||||
manifest: SessionInit = session.manifest # type: ignore[assignment]
|
||||
await self._recv_q.put(manifest.model_dump(by_alias=True))
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._bridge is not None and self._session_id is not None:
|
||||
self._bridge.close_session(self._session_id)
|
||||
self._session_id = None
|
||||
self._connected = False
|
||||
|
||||
async def send(self, message: dict[str, Any]) -> None:
|
||||
if not self._connected or self._bridge is None or self._session_id is None:
|
||||
raise ConnectionError_("Transport is not connected.")
|
||||
sid = message.get("session_id") or self._session_id
|
||||
msg_type = message.get("type")
|
||||
# session_resume carries the session_id; bind it
|
||||
if msg_type == "session_resume" and not self._bridge._sessions.get(sid): # noqa: SLF001
|
||||
# Auto-open if bridge forgot
|
||||
pass
|
||||
response = await self._bridge.handle_message(sid, message)
|
||||
if response is not None:
|
||||
await self._recv_q.put(response)
|
||||
|
||||
async def receive(self) -> dict[str, Any]:
|
||||
if not self._connected:
|
||||
raise ConnectionError_("Transport is not connected.")
|
||||
return await self._recv_q.get()
|
||||
|
||||
async def push_event(self, event: dict[str, Any]) -> None:
|
||||
"""Bridge-side helper to push an event to the connected client."""
|
||||
await self._recv_q.put(event)
|
||||
|
||||
async def __aenter__(self) -> "InProcessTransport":
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
await self.close()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""WebSocket transport for AICC: client and server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aicc.bridge import Bridge
|
||||
from aicc.errors import ConnectionError_
|
||||
|
||||
|
||||
class WebSocketClientTransport:
|
||||
"""AICC client transport over WebSocket.
|
||||
|
||||
Example:
|
||||
t = WebSocketClientTransport("ws://localhost:8765")
|
||||
client = AICCClient(t)
|
||||
"""
|
||||
|
||||
def __init__(self, uri: str):
|
||||
self._uri = uri
|
||||
self._ws = None # type: ignore[var-annotated]
|
||||
|
||||
async def connect(self) -> None:
|
||||
try:
|
||||
import websockets # type: ignore
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ConnectionError_(
|
||||
"websockets is required for WebSocketClientTransport. "
|
||||
"Install with: pip install aicc[ws] or pip install websockets"
|
||||
) from e
|
||||
self._ws = await websockets.connect(self._uri, max_size=64 * 1024 * 1024)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._ws is not None:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
async def send(self, message: dict[str, Any]) -> None:
|
||||
if self._ws is None:
|
||||
raise ConnectionError_("WebSocket not connected.")
|
||||
await self._ws.send(json.dumps(message))
|
||||
|
||||
async def receive(self) -> dict[str, Any]:
|
||||
if self._ws is None:
|
||||
raise ConnectionError_("WebSocket not connected.")
|
||||
raw = await self._ws.recv()
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
class WebSocketServer:
|
||||
"""AICC server transport: serves a Bridge over WebSocket.
|
||||
|
||||
Example:
|
||||
bridge = Bridge(name="room")
|
||||
async with WebSocketServer(bridge, port=8765):
|
||||
await asyncio.Future() # run forever
|
||||
"""
|
||||
|
||||
def __init__(self, bridge: Bridge, host: str = "127.0.0.1", port: int = 8765):
|
||||
self._bridge = bridge
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._server = None # type: ignore[var-annotated]
|
||||
self._connections: set[Any] = set()
|
||||
|
||||
async def __aenter__(self) -> "WebSocketServer":
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
await self.stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
try:
|
||||
import websockets # type: ignore
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ConnectionError_(
|
||||
"websockets is required for WebSocketServer. "
|
||||
"Install with: pip install websockets"
|
||||
) from e
|
||||
|
||||
async def handler(ws) -> None: # type: ignore[no-untyped-def]
|
||||
self._connections.add(ws)
|
||||
session = self._bridge.open_session()
|
||||
try:
|
||||
manifest = session.manifest
|
||||
if manifest is not None:
|
||||
await ws.send(json.dumps(manifest.model_dump(by_alias=True)))
|
||||
async for raw in ws:
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
msg = json.loads(raw)
|
||||
response = await self._bridge.handle_message(session.session_id, msg)
|
||||
if response is not None:
|
||||
await ws.send(json.dumps(response))
|
||||
finally:
|
||||
self._bridge.close_session(session.session_id)
|
||||
self._connections.discard(ws)
|
||||
|
||||
self._server = await websockets.serve(handler, self._host, self._port)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
self._server = None
|
||||
for ws in list(self._connections):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._connections.clear()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Minimal AICC agent connecting to the bridge example.
|
||||
|
||||
Run bridge_minimal.py first, then:
|
||||
python examples/agent_minimal.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aicc import AICCClient
|
||||
from aicc.transport import WebSocketClientTransport
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AICCClient(WebSocketClientTransport("ws://127.0.0.1:8765")) as client:
|
||||
manifest = await client.handshake()
|
||||
print(f"Connected to world: {manifest.world.name} ({manifest.world.kind})")
|
||||
print(f"Tools: {[t.id for t in manifest.tools]}")
|
||||
|
||||
res = await client.call_tool("proprioception", {})
|
||||
print(f"proprioception -> {res.output}")
|
||||
|
||||
res = await client.call_tool("move", {"forward": 2.5})
|
||||
print(f"move -> {res.output}")
|
||||
|
||||
res = await client.call_tool("turn", {"yaw": -45})
|
||||
print(f"turn -> {res.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Minimal AICC bridge with a capsule in a room.
|
||||
|
||||
Run:
|
||||
python examples/bridge_minimal.py
|
||||
|
||||
Then connect from another terminal:
|
||||
python examples/agent_minimal.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aicc import Bridge
|
||||
from aicc.transport import WebSocketServer
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
bridge = Bridge(name="capsule-room", kind="3d", tick_rate_hz=10.0)
|
||||
|
||||
@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 in meters.")
|
||||
async def move(forward: float = 0.0) -> dict:
|
||||
return {"moved": forward}
|
||||
|
||||
@bridge.tool(description="Turn the agent's heading by the given yaw angle in degrees.")
|
||||
async def turn(yaw: float = 0.0) -> dict:
|
||||
return {"turned": yaw}
|
||||
|
||||
print(f"AICC bridge '{bridge.name}' listening on ws://127.0.0.1:8765")
|
||||
async with WebSocketServer(bridge, port=8765):
|
||||
await bridge.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aicc"
|
||||
version = "0.1.0"
|
||||
description = "Python SDK for the AI-Controlled Character Protocol"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Emil Shanaty", email = "emil28092005@gmail.com" }]
|
||||
keywords = ["aicc", "protocol", "agent", "llm", "gamedev", "embodied-ai"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"pydantic>=2.5",
|
||||
"websockets>=12",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Protocol = "https://github.com/emil28092005/AICC-Protocol"
|
||||
Issues = "https://github.com/emil28092005/aicc-py/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["aicc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
@@ -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