Skip to content

Latest commit

 

History

History
407 lines (315 loc) · 20.6 KB

File metadata and controls

407 lines (315 loc) · 20.6 KB

amplifier-core Contracts

Purpose: This document is the authoritative Rust↔Python type mapping for coding agents working on either side of the boundary. Read this before modifying any shared type, trait/protocol, or error.

Naming Convention

Concept Rust Python
Data model struct Foo with #[derive(Serialize, Deserialize)] class Foo(BaseModel) (Pydantic v2)
Interface trait Bar (async, dyn-safe) class Bar(Protocol) (structural typing)
Enum (string) enum Baz { Variant } with #[serde(rename_all = "snake_case")] Literal["variant"]
Tagged union enum E { A { .. }, B { .. } } with #[serde(tag = "type")] Discriminated Union[A, B]
Error Result<T, E> with thiserror T (raises exception)
Optional Option<T> T | None
List Vec<T> list[T]
Map HashMap<K, V> dict[K, V]
JSON blob serde_json::Value dict[str, Any]

Serialization boundary: All data crosses the PyO3 bridge as JSON (via serde_json::to_stringjson.loads and vice versa). Field names must be identical on both sides. Rust uses #[serde(rename = "...")] where the Rust field name differs from the JSON key.


Trait ↔ Protocol Mapping

Rust Trait Location (Rust) Python Protocol Location (Python) Notes
Tool crates/amplifier-core/src/traits.rs Tool interfaces.py Rust execute takes Value; Python takes dict[str, Any]. Rust adds get_spec() -> ToolSpec.
Provider crates/amplifier-core/src/traits.rs Provider interfaces.py 1:1 — name, get_info, list_models, complete, parse_tool_calls.
Orchestrator crates/amplifier-core/src/traits.rs Orchestrator interfaces.py Rust passes hooks/coordinator as Value; Python passes typed objects + **kwargs.
ContextManager crates/amplifier-core/src/traits.rs ContextManager interfaces.py 1:1 — add_message, get_messages_for_request, get_messages, set_messages, clear.
HookHandler crates/amplifier-core/src/traits.rs HookHandler interfaces.py Rust: handle(event, data); Python: __call__(event, data).
ApprovalProvider crates/amplifier-core/src/traits.rs ApprovalProvider interfaces.py 1:1 — request_approval(ApprovalRequest) -> ApprovalResponse.

Data Model Mapping

Core Models (models.rsmodels.py)

Rust Struct/Enum Python Class Serialization Notes
HookResult HookResult (BaseModel) JSON round-trip at PyO3 boundary Field-for-field match. Rust HookAction enum ↔ Python Literal strings.
HookAction Literal["continue","deny","modify","inject_context","ask_user"] serde(rename_all = "snake_case") Enum variants map 1:1 to string literals.
ToolResult ToolResult (BaseModel) JSON round-trip 1:1. Python adds __str__() and get_serialized_output() convenience methods.
ModelInfo ModelInfo (BaseModel) JSON round-trip 1:1.
ConfigField ConfigField (BaseModel) JSON round-trip Rust default_value ↔ Python default.
ConfigFieldType Literal["text","secret","choice","boolean"] snake_case
ProviderInfo ProviderInfo (BaseModel) JSON round-trip 1:1.
ModuleInfo ModuleInfo (BaseModel) JSON round-trip Rust module_type serializes as JSON key "type".
ModuleType Literal["orchestrator","provider","tool","context","hook","resolver"] snake_case
SessionStatus SessionStatus (BaseModel) JSON round-trip 1:1. Rust started_at is String; Python is datetime.
SessionState Literal["running","completed","failed","cancelled"] snake_case
ContextInjectionRole Literal["system","user","assistant"] snake_case
ApprovalDefault Literal["allow","deny"] snake_case
UserMessageLevel Literal["info","warning","error"] snake_case
ApprovalRequest ApprovalRequest (BaseModel) JSON round-trip 1:1. Python has model_post_init validation.
ApprovalResponse ApprovalResponse (BaseModel) JSON round-trip 1:1.

Message Models (messages.rsmessage_models.py)

Rust Type Python Type Serialization Notes
Message Message (BaseModel) JSON round-trip 1:1 fields.
ContentBlock (tagged enum) ContentBlockUnion (discriminated Union) serde(tag = "type") Rust variants = Python separate BaseModel classes.
ToolSpec ToolSpec (BaseModel) JSON round-trip 1:1.
ChatRequest ChatRequest (BaseModel) JSON round-trip 1:1.
ToolCall ToolCall (BaseModel) JSON round-trip 1:1.
Usage Usage (BaseModel) JSON round-trip 1:1.
Degradation Degradation (BaseModel) JSON round-trip 1:1.
ChatResponse ChatResponse (BaseModel) JSON round-trip 1:1.
ResponseFormat (tagged enum) ResponseFormat (Union) serde(tag = "type") Text/Json/JsonSchema variants match.
Role (enum) Literal["system","developer","user","assistant","function","tool"] snake_case
Visibility (enum) Literal["internal","developer","user"] snake_case

Content Block Variants

Python uses separate classes for each content block type. Rust uses variants of the ContentBlock enum.

Rust Variant Python Class Location (Python)
ContentBlock::Text TextBlock message_models.py
ContentBlock::Thinking ThinkingBlock message_models.py
ContentBlock::RedactedThinking RedactedThinkingBlock message_models.py
ContentBlock::ToolCall ToolCallBlock message_models.py
ContentBlock::ToolResult ToolResultBlock message_models.py
ContentBlock::Image ImageBlock message_models.py
ContentBlock::Reasoning ReasoningBlock message_models.py

Streaming Content Models (content_models.py)

Rust Equivalent Python Class Notes
ContentBlock::Text variant TextContent (dataclass)
ContentBlock::Thinking variant ThinkingContent (dataclass)
ContentBlock::ToolCall variant ToolCallContent (dataclass)
ContentBlock::ToolResult variant ToolResultContent (dataclass)
ContentBlockType enum ContentBlockType (str Enum) Text/Thinking/ToolCall/ToolResult

Behavioral Type Mapping

These are the core engine types that the Rust kernel implements and the PyO3 bridge exposes.

Rust Type PyO3 Wrapper Python Name Python Original Notes
Session RustSession AmplifierSession session.py:AmplifierSession Rust is the default export. Rust is leaner: no ModuleLoader, no auto-load in initialize().
Coordinator RustCoordinator ModuleCoordinator coordinator.py:ModuleCoordinator Rust is the default export. Rust has core mount/get/hooks/cancel. Python adds process_hook_result, session back-refs, budget limits.
HookRegistry RustHookRegistry HookRegistry hooks.py:HookRegistry Rust is the default export. 1:1 core API: register, emit, unregister, list_handlers.
CancellationToken RustCancellationToken CancellationToken cancellation.py:CancellationToken Rust is the default export. 1:1: state, is_cancelled, request_graceful, request_immediate, reset.
CancellationState (stays Python) CancellationState cancellation.py:CancellationState Simple enum — no Rust bridge needed.
SessionConfig (internal) (dict) (inline in __init__) Rust-specific typed config. Python uses raw dict.

The switchover from Python to Rust implementations is complete. Rust types are now the default exports for top-level imports (from amplifier_core import ...). Python implementations remain accessible via submodule imports for backward compatibility. The Rust* prefixed names (RustSession, RustHookRegistry, etc.) are still available as explicit aliases.


Error Mapping

LLM/Provider Errors (errors.rs:ProviderErrorllm_errors.py)

Rust Variant Python Exception Notes
ProviderError::RateLimit RateLimitError Rust has retry_after: Option<f64> field.
ProviderError::Authentication AuthenticationError
ProviderError::ContextLength ContextLengthError
ProviderError::ContentFilter ContentFilterError
ProviderError::InvalidRequest InvalidRequestError
ProviderError::Unavailable ProviderUnavailableError
ProviderError::Timeout LLMTimeoutError
ProviderError::Other LLMError (base class) Catch-all.

Session Errors (errors.rs:SessionError)

Rust Variant Python Equivalent Notes
SessionError::NotInitialized RuntimeError("No orchestrator...") Python raises generic RuntimeError.
SessionError::ConfigMissing ValueError("Configuration must specify...")
SessionError::AlreadyCompleted (no equivalent) Rust-only guard.
SessionError::Other (various RuntimeErrors)

Hook Errors (errors.rs:HookError)

Rust Variant Python Equivalent Notes
HookError::HandlerFailed (logged, not raised) Python catches silently.
HookError::Timeout TimeoutError (via asyncio.wait_for)
HookError::Other (generic Exception)

Rust-Only Error Types

Rust Type Notes
AmplifierError Top-level wrapper enum. Python has no unified equivalent.
ToolError Python represents tool errors as ToolResult(success=False, error={...}).
ContextError Python context managers raise generic exceptions.

Python-Only Types (Not Ported to Rust)

These types stay as Python by design — they are app-layer concerns, not kernel.

Python Type Location Why Not Ported
ModuleLoader loader.py Module loading/discovery is app-layer.
ModuleValidationError loader.py Validation framework stays Python.
ApprovalSystem approval.py App-layer approval policy.
DisplaySystem display.py App-layer UX.
validation/ package validation/ Structural + behavioral test framework stays Python.
testing module testing.py Test utilities (MockTool, TestCoordinator, etc.) stay Python.
pytest_plugin pytest_plugin.py Pytest integration stays Python.
cli cli.py CLI entry point stays Python.

Module Lifecycle Methods

Modules may expose lifecycle methods that the kernel calls at specific points during initialisation.

mount(coordinator, config) — Required

from collections.abc import Awaitable, Callable
from typing import Any

async def mount(coordinator, config: dict[str, Any] | None = None) -> Callable[[], None | Awaitable[None]] | None:
    ...

Module-level free function — no self. Called once per module, in phase order, when the kernel loads and wires the module into the coordinator. At call time the coordinator is partially composed — modules from earlier phases are accessible, but later-phase modules may not yet be present.

Return value — optional cleanup callable:

mount() may return a zero-argument cleanup callable, or None (no cleanup needed).

Returned value Kernel behaviour
None Silently ignored — no cleanup registered
Non-callable (e.g. dict) Silently ignoredregister_cleanup only stores callables
Sync callable Stored; called with no args at teardown. If the call returns a coroutine it is awaited
Async callable Stored; at teardown the coroutine is obtained and awaited via pyo3_async_runtimes::tokio::into_future

The canonical form is an async def cleanup() nested closure:

async def mount(coordinator, config: dict[str, Any] | None = None):
    client = await MyClient.connect((config or {}).get("url"))
    coordinator.register_capability("my.client", client)

    async def cleanup() -> None:
        await client.aclose()

    return cleanup  # kernel awaits this at session teardown

Cleanup callables are called with no arguments, in reverse registration order (last-mounted module is cleaned up first). Errors in one cleanup callable are logged but do not prevent the remaining callables from running.

Source reference: bindings/python/src/coordinator/mod.rs::cleanup() and bindings/python/src/coordinator/capabilities.rs::register_cleanup().

on_session_ready(coordinator) — Optional

async def on_session_ready(coordinator) -> None:
    ...

Called after ALL modules across all phases have completed mount(). By the time on_session_ready() runs, the coordinator is fully composed and every contributed tool, hook, and provider is registered.

on_session_ready() Contract

Property Value
Presence Optional
Signature async def on_session_ready(coordinator) -> None — no config param, no self
Sync check Must be async; a sync on_session_ready logs a warning and is skipped
Return value Ignored
Exceptions Non-fatal — caught and logged as warnings with exc_info=True
Call order Same as mount() order (phase order, then registration order within a phase)
Timing After all phases complete mount(), before session:fork is emitted
Scope Python-only — see polyglot note below

Fork behaviour: on_session_ready fires once per session. When a parent session forks a child session (the session:fork event), the child runs its own independent mount wave and its own on_session_ready pass — in the same order as its own module registration. A parent's on_session_ready callbacks do not run again for the child.

Dispatch ordering: Callbacks fire sequentially in module registration order (orchestrator first, then context, then providers, tools, and hooks within each phase in load order). This ordering is stable and guaranteed. Cross-module assumptions built on this ordering are safe.

Failure isolation: A raised exception in one module's on_session_ready is caught, logged as a WARNING with exc_info=True, and does not prevent remaining modules' callbacks from running.

No timeout: The kernel enforces no timeout on on_session_ready callbacks. A hanging callback hangs the session. This is a deliberate absence — documenting it now prevents surprise if a timeout is added later (adding one is a breaking change).

Observability event: On on_session_ready failure, the kernel emits a module:on_session_ready_failed event with payload {"module_id": str, "error": str}, in addition to the WARNING log. Log-only failures are invisible to observability hooks.

Same mechanism for provider/tool/hook load failures: A provider, tool, or hook that raises during mount() is caught, logged as a WARNING with exc_info=True, and does not abort the session — the remaining modules still load and the session still starts. This is intentional non-interference (a single broken optional module must not take down an otherwise-working session) but it is silent by default: nothing short of grepping logs tells you a configured module never mounted. The kernel emits a module:load_failed event with payload {"module_type": "provider"|"tool"|"hook", "module_id": str, "error": str} so a hook module can observe the gap and decide policy (abort, notify the user, inject a system message telling the model the tool is unavailable, etc.). Required modules (orchestrator, context manager) are unaffected by this — their failure still raises RuntimeError and aborts initialization, as documented above.

When to use on_session_ready()

  • Discovering contributions from other modules — read the fully-composed coordinator to inspect tools, hooks, or providers registered by peers.
  • Wiring cross-module dependencies — subscribe to hooks or capabilities that are only present after another module mounts.
  • Eliminating dual-path patterns — avoid the anti-pattern of checking for a capability in both mount() (not yet available) and in a request handler (too late to configure).

Before on_session_ready (dual-path anti-pattern) — process-scoped global, unsafe for multi-session deployments:

# Before (anti-pattern) — process-scoped global, unsafe for multi-session deployments
_coordinator = None  # ← shared across ALL sessions in the process

async def mount(coordinator, config: dict | None = None) -> None:
    global _coordinator
    _coordinator = coordinator

async def handle_request(request):
    if _coordinator and _coordinator.get("tools", {}).get("search"):
        result = await _coordinator.get("tools")["search"].execute(**request.params)

After on_session_ready (correct) — session-scoped via closure, safe for multi-session deployments:

# After (correct) — session-scoped via closure, safe for multi-session deployments
async def mount(coordinator, config: dict | None = None) -> None:
    pass  # nothing cross-module to wire here

async def on_session_ready(coordinator) -> None:
    # Session-scoped: register_capability stores reference per-session on the coordinator
    tools = coordinator.get("tools") or {}
    search_tool = tools.get("search")
    if search_tool:
        coordinator.register_capability("my_module.search_tool", search_tool)

async def handle_request(coordinator, request):
    search_tool = coordinator.get_capability("my_module.search_tool")
    if search_tool:
        result = await search_tool.execute(**request.params)

Cleanup from on_session_ready(): on_session_ready()'s return value is ignored. If a resource allocated in on_session_ready() needs teardown, register the cleanup directly: coordinator.register_cleanup(my_async_cleanup_fn).

Canonical use case: event subscription after full composition

# Canonical on_session_ready pattern: event discovery + subscription
# (from hooks-logging, the primary motivating adopter)

async def mount(coordinator, config: dict | None = None):
    pass  # store config-derived state if needed

async def on_session_ready(coordinator) -> None:
    # All modules mounted — event contributions are complete
    all_events = set(ALL_EVENTS)  # core canonical events
    contributions = await coordinator.collect_contributions("observability.events")
    for event_list in contributions:
        all_events.update(event_list)
    # delegate:agent_spawned, delegate:agent_completed are now guaranteed present
    for event in all_events:
        coordinator.hooks.register(event, my_handler, name="my-module")

Polyglot note

on_session_ready() is Python-only. WASM, gRPC, and native Rust modules do not participate in the on_session_ready() wave. If a polyglot module needs post-composition behaviour, defer it to a request-time check or emit a custom event that Python modules can subscribe to.


Event Constants

Canonical event names are defined in crates/amplifier-core/src/events.rs (Rust) and re-exported in amplifier_core.events (Python). The full set contains 41+ events; the table below lists the most commonly referenced ones. Always use the constants from the events module rather than hard-coding strings.

Event Value
Execution start "execution:start"
Execution end "execution:end"
Session start "session:start"
Session end "session:end"
LLM request "llm:request"
LLM response "llm:response"
Tool pre "tool:pre"
Tool post "tool:post"
Context compaction "context:compaction"

Rules for Modifying Shared Types

  1. Field names must match. If you add a field to a Rust struct, add the identical field to the Python BaseModel (and vice versa).

  2. Enum variants must match. Rust snake_case serde names = Python Literal string values.

  3. JSON is the contract. Both sides must produce identical JSON for the same logical value. Test with round-trip serialization.

  4. Update this document. Any change to a shared type must be reflected here. CI will eventually enforce this.

  5. Method names must match for PyO3-bridged types (Session, Coordinator, HookRegistry, CancellationToken). The Python-visible name is set by #[pyclass(name = "...")] and #[pymethods].