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:
Emil Shanaty
2026-08-08 03:10:35 +03:00
commit 0cfb4277b5
22 changed files with 1698 additions and 0 deletions
+19
View File
@@ -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
+21
View File
@@ -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.
+112
View File
@@ -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`.
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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}'"
)
+35
View File
@@ -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."""
+193
View File
@@ -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
View File
+113
View File
@@ -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
+96
View File
@@ -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
+13
View File
@@ -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
+20
View File
@@ -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]: ...
+87
View File
@@ -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()
+116
View File
@@ -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()
+30
View File
@@ -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())
+45
View File
@@ -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
+48
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Package marker."""
+164
View File
@@ -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"]
+57
View File
@@ -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"
+32
View File
@@ -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"}