- conformance.py: declarative scenario runner (subset matcher, $kind/$gt/ $enum/$required assertions, call_id/session_id echo checks), reference bridge, CLI entry (python -m aicc.conformance) - bridge.py: protocol version check before parsing (protocol_mismatch), session expiry handling (session_expired), malformed tool_call -> invalid_input, emit_event() for async events - client.py: Self return type - in_process.py: drain bridge event queue after response (event delivery), Self return type - websocket.py: Self return type, best-effort shutdown - tests: 14 passing (incl. conformance scenarios, 9/9 core scenarios) - ruff: all checks pass
184 lines
5.9 KiB
Python
184 lines
5.9 KiB
Python
"""AICCClient: agent side of the AICC protocol."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from typing import Any, Self
|
|
|
|
from aicc.errors import (
|
|
AICCError,
|
|
ConnectionError_,
|
|
ProtocolError,
|
|
ToolError,
|
|
)
|
|
from aicc.protocol import (
|
|
PROTOCOL_VERSION,
|
|
EventMessage,
|
|
MessageType,
|
|
SessionClose,
|
|
SessionInit,
|
|
ToolCall,
|
|
ToolResult,
|
|
)
|
|
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) -> Self:
|
|
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}'"
|
|
)
|