amplifier-core Contracts
April 25, 2026 · View on GitHub
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_string → json.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.rs ↔ models.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.rs ↔ message_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. TheRust*prefixed names (RustSession,RustHookRegistry, etc.) are still available as explicit aliases.
Error Mapping
LLM/Provider Errors (errors.rs:ProviderError ↔ llm_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 ignored — register_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.
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 inon_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
-
Field names must match. If you add a field to a Rust struct, add the identical field to the Python BaseModel (and vice versa).
-
Enum variants must match. Rust
snake_caseserde names = PythonLiteralstring values. -
JSON is the contract. Both sides must produce identical JSON for the same logical value. Test with round-trip serialization.
-
Update this document. Any change to a shared type must be reflected here. CI will eventually enforce this.
-
Method names must match for PyO3-bridged types (
Session,Coordinator,HookRegistry,CancellationToken). The Python-visible name is set by#[pyclass(name = "...")]and#[pymethods].