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
+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