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
+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}'"
)