posoco

    LLM Agent framework with hexagonal (ports-and-adapters) architecture. Defines 9 traits + Agent loop. Depends on moonbitlang/async.

    llm
    agent
    framework
    ports-and-adapters
    ai-runtime
    Download zip
    Author
    Version
    0.14.4
    License
    Apache-2.0
    Last updated
    1 hour ago
    Downloads
    168

    Dependencies

    #Posoco

    Posoco is a protocol-first LLM agent framework for MoonBit: the core owns the agent loop and its invariants, and every runtime capability — model, tool, session, observer, hook, UI, command, prompt, memory, lifecycle — is injected through open port traits. Products compose one Agent(exts~, config~); the core never dictates shape.

    WARNING: Posoco is experimental (0.x). The public API is subject to change.

    #Installation

    moon add colmugx/posoco@0.13.1

    #Ports

    Ports are the only seams between Posoco core and the capabilities an agent uses.

    PortPurpose
    ModelPortModel chat + context compaction (chat, compact, provider_config)
    ToolProviderTool discovery and execution
    SessionStoreLoad / save conversation session
    ObserverRead-only turn-event observation
    HookPipeline interception with default methods: rewrite messages or abort before model (before_model), approve / defer / reject before tool (before_tool), read-only after each effect (on_post_event)
    MemoryPortLong-term memory storage and retrieval
    LifecycleAsync resource cleanup on shutdown
    CommandPortUser-side slash-command enumeration and dispatch
    UiPortStructured UI intents + interaction requests (Status / Notice / Widget / Input / Confirm / Select)
    SystemPromptContributorDeclare system-prompt sections (assembled and injected before the model call)
    ExtensionSelf-report protocol: extension_id + manifest declaring which ports an extension contributes
    ProviderConfigModel-side provider config (companion type, not a runtime port)

    #create an extension

    An extension is a struct that implements one or more port traits and @posoco.Extension. The same self reference goes in every manifest slot whose port the struct implements; the id string is used only for composition diagnostics (tool-collision messages, observer attribution), never for routing or persistence.

    The minimal shape is two methods plus one pub extend line:

    // 1. Implement the port trait(s) your extension contributes.
    // (ToolProvider shown here; the body is omitted for brevity.)

    ///|
    pub impl @posoco.Extension for ReadTools with fn extension_id(_self) -> String {
    "posoco_ext_read"
    }

    ///|
    /// 2. Declare which ports ReadTools contributes. The same `self` goes under
    /// every slot whose trait ReadTools implements; the rest stay empty.
    pub impl @posoco.Extension for ReadTools with fn manifest(self) -> @posoco.ExtensionManifest {
    {
    id: "posoco_ext_read",
    models: [],
    tools: [self],
    sessions: [],
    observers: [],
    hooks: [],
    memory: [],
    lifecycle: [],
    commands: [],
    ui: [],
    prompt_contributors: [],
    requires: [],
    }
    }

    ///|
    /// 3. Expose Extension methods for dot-syntax callers and so `&ReadTools`
    /// coerces to `&@posoco.Extension` inside `Array[&@posoco.Extension]`.
    pub ReadTools with @posoco.Extension::{extension_id, manifest}

    ///|
    /// 4. Optional factory for hosts that construct from defaults.
    pub fn read_extension() -> ReadTools {
    ReadTools::ReadTools()
    }

    For a ModelPort extension, put self under models: [self] instead and leave tools: [].

    #compose an agent

    Agent authors import extension packages and wire their instances into one Agent. exts is an array of self-reporting extensions — order-independent; Agent aggregates their manifests.

    let agent = @posoco.Agent(
    exts=[
    model_ext, // an extension contributing ModelPort
    read_ext, // the ReadTools extension built above, contributing ToolProvider
    ],
    config={
    max_tool_rounds: Some(10),
    temperature: None,
    max_output_tokens: None,
    model_context_window: None,
    },
    )

    let input : @posoco.Message = @posoco.UserMessage(content=[
    @posoco.Content::Text("hello"),
    ])
    let result = agent.run_turn(input, "session_1")

    AgentConfig has four fields: max_tool_rounds (Int?None is unbounded and the recommended default; Some(n) allows n full tool rounds), temperature, max_output_tokens, model_context_window. run_turn(message, session_id) is async and raises AgentError. Composition is fail-fast and raises CompositionError:

    • MissingModel — no extension contributes ModelPort
    • MultipleModels — more than one extension contributes ModelPort directly (multi-model routing belongs inside a meta-extension such as posoco-ext-llm, not in the core)
    • ToolCollision — two extensions register the same tool name (no last-wins)
    • EmptyPort("SessionStore") — no extension contributes a required port

    A turn that has emitted TurnStarted emits exactly one terminal event: TurnCompleted on success, or TurnFailed on any primary failure.

    #Extension library

    colmugx/posoco-extension maintains a curated set of ready-to-use extensions, and other port implementations you can drop straight into Agent(exts~, config~).

    #Learn more — Posoco 101

    posoco-101/ is a chapter-by-chapter course that builds from "what is an agent loop" up to a mini coding agent. The outline and chapter status live in posoco-101/OUTLINE.md:

    #ChapterStatus
    01Agent Loop principles: how to write your own
    02Your first agent with Posoco (10 lines)

    #Development & validation

    moon check --output-json moon test --output-json moon fmt moon info

    #Why "Posoco"?

    "Posoco" comes from persocom — the humanoid computers in CLAMP's manga Chobits. Since Posoco is the agent runtime split out of Elyra's architectural philosophy, it takes the homophone Posoco.

    #License

    Apache-2.0.

    AgentError

    AutocompleteItem

    AutocompleteSource

    A source of autocomplete suggestions tied to a trigger character.

    CallId

    Stable identifier for a single tool call requested by the assistant. The reducer and scheduler only ever compare/store this string; it is opaque to the Kernel and is produced by the model adapter or by the host.

    Capability

    A composed capability an extension declares it consumes. Declaring is what populates the CompositionView delivered at Lifecycle::on_compose — the view gates on this declaration, so an undeclared capability reads as None. Extensible by adding variants; existing manifests are unaffected.

    ChatOptions

    Provider-agnostic chat options. Trimmed in R3 M3.7 to just the two fields every provider accepts. Provider-specific tuning (tool_choice, reasoning effort, top_p, frequency_penalty, ...) belongs on the modelport's own config struct, not on this shared bag — that way adding a new provider never bloats the common type.

    CommandDef

    Command declaration. params is the source of truth for argument rendering (panel form) and parsing (slash). Redundant fields (aliases/shortcut/icon/ visible/metadata) are deliberate "escape hatches" for ext authors to use without changing the trait — meta does not define their semantics.

    CommandError

    Errors raised by CommandPort.invoke: unknown command id, invalid args, or execution failure.

    CommandOutcome

    Outcome of invoking a command. feedback is for slash (print directly); structured + ui_hint are for panel (deep render + UI action). NeedsInput supports multi-step commands.

    CommandParam

    A single command parameter. Explicit and human-facing, in contrast to ToolDef.input_schema (which is JSON Schema for LLM consumption).

    CommandPort

    User-initiated, enumerable, side-effecting commands. Distinct from ToolProvider (LLM-initiated, results re-enter session). Both panel TUIs and slash command lines consume this without knowing command semantics. pub(open) so any ext can declare commands.

    CommandType

    Semantic command type. Determines how a panel renders the control (button / toggle / dropdown / input / multi-step form) and how a slash parser expects arguments.

    CompactMode

    How posoco applies the result of ModelPort::compact.

    CompactResult

    Return value of ModelPort::compact.

    CompactTrigger

    What triggered a compact. Advisory — modelport may ignore.

    Completion

    A completed assistant step. The message field is the canonical assistant message that the reducer appends to the transcript; tool calls and reasoning live there (single source of truth, ADR §2.11). The usage field reports token consumption observed for this step, which the reducer may aggregate into the Run budget.

    Construction note: do NOT build this by hand in production code. The from_assistant_message constructor enforces the invariant that the message payload is consistent (assistant role, no ToolMessage shape, etc.).

    CompletionPayload

    Re-flattened assistant payload used inside Completion. We cannot reuse Message::AssistantMessage directly because that variant already carries finish_reason; the completion's message field is the transcript entry, while the usage lives at the completion level. This struct mirrors only the fields that travel together as the assistant's contribution to a single step.

    CompositionError

    Composition errors raised during Agent::new (manifest aggregation failures).

    Every variant that involves a specific extension carries manifest_id so callers can locate the offending extension without guessing. Multi-party collisions carry the list of involved manifest ids.

    CompositionView

    CompositionView: the curated read-only view of composed capabilities delivered to Lifecycle::on_compose after every composition gate has passed. This is the consumption direction of the manifest contract — an extension declares requires and the corresponding getter reads Some; undeclared capabilities read None.

    Curation discipline: the view only ever carries capabilities whose use cannot bypass governance. model calls still carry InvocationScope attribution; ui has no governance surface. The view NEVER exposes tool invocation (would bypass PipelineHook::before_tool), session stores (state ownership), or observer-event emission (core-owned). Fields are private with getters so new capabilities can be added without breaking extensions that destructure or construct views in tests.

    Content

    Canonical content block. Text is the common case; Image carries a stable media-type identifier plus opaque payload data (base64, URL, or an adapter-managed handle — the Kernel does not interpret it).

    ContextPressure

    Context-pressure advisory for a model-side call. Posoco resolves the effective window and threshold per turn (host config > provider report > core default) and tracks the latest verified occupancy; compact is the call that needs these numbers, so the pump attaches them to the InvocationScope instead of leaving modelports to re-estimate from characters. Read-only for ports; absent dimensions are genuinely unknown and must not be guessed.

    EventScope

    Attribution identity of one emitted TurnEvent / HookStage: which session and run the event belongs to. Projected from the committed event envelope at the Agent boundary — never reconstructed by scanning a transcript. Scope answers "which run does this belong to"; it does not carry effect identity (ToolCall.call_id already correlates tool events).

    ExecutionPolicy

    How a tool may be batched with other tools in the same model completion.

    • Sequential: occupies a single-element wave. Safe default; file/shell side effects never race.
    • Parallel: may share a wave with adjacent Parallel calls.
    • Exclusive: forms a barrier before and after; never shares a wave.

    Sequential is the safe default. Products opt into Parallel only for tools whose effects are independently safe, and use Exclusive at a visible side-effect boundary.

    Extension

    ExtensionManifest

    Self-reported port contributions. All fields default to empty arrays; multi-trait extensions put self under each applicable field.

    requires is the consumption direction: the extension declares which composed capabilities it wants delivered at Lifecycle::on_compose. The declaration is load-bearing (the view gates on it) and auditable (composition can answer "which extensions declared model access").

    FinishReason

    Why the assistant finished a step. It is owned by the assistant message (per ADR §2.11: single source of truth).

    Length is preserved as a distinct variant because the reducer must NOT emit ExecuteTool effects after a length-truncated completion, even if the provider tried to stream partial tool calls.

    HookAbort

    Raised by PipelineHook::before_model to stop the run before the model is called.

    HookStage

    Describes what just completed, delivered after the effect is committed.

    InvocationScope

    Invocation identity for a single model-side call (ModelPort::chat / ModelPort::compact, and their Runtime correspondences). Constructed by posoco at the effect-interpretation layer and passed down unchanged; ports and hosts MUST treat it as read-only.

    This is the canonical answer to "which session/run is this call serving": adapters that key behaviour on session identity (continuity, telemetry, cost attribution, per-session policy) read it from here instead of smuggling it through messages or config.

    Invariants:
    • session_id / run_id always identify the Run that issued the call, including compact (compact is host-driven but still scoped to a Run).
    • effect_id is Some iff the call executes a reducer-allocated CallModel effect; it is None for compact, which is not an effect. Hosts that propagate cancellation key in-flight model calls by this id, mirroring the tool side's EffectContext.effect_id.
    • pressure is Some only where posoco has a reading worth sharing (the compact path); ports treat it as advisory read-only input.

    Lifecycle

    Lifecycle: the extension-facing agent lifecycle, three phases.

    • on_compose: called once, synchronously, after every composition gate has passed (catalog atomic snapshot, model cardinality, collision checks) and before the Agent is ready. This is where the composed capabilities declared in the manifest's requires are delivered via CompositionView. Sync on purpose: ModelPort::chat and UiPort::request are async, so a sync composition point cannot use what it wires — the type system enforces "wire, don't act". Raise CompositionError::ExtensionComposeFailed to fail the composition loudly; no partial Agent is produced.
    • on_start: called once per Agent lifetime, inside the first run_turn (an async context), before the first TurnStarted is projected. This is the legitimate birth point for loading persisted state or spawning background loops — construction time has no async root. Not re-fired after Suspended resumes. Like other async slots the body may raise; the Agent wraps a raised error loudly and the first turn fails before any terminal event is emitted.
    • on_shutdown: resource cleanup hook, called once on agent shutdown in reverse registration order; cleanup failure propagates (loud).

    All methods have defaults: an extension overrides only the phases it cares about, and adding a phase is non-breaking (same pattern as PipelineHook).

    MemoryError

    MemoryPort

    MemoryPort: session memory — the port a memory system plugs into (nowledge-mem today, OpenViking and any other scheme tomorrow). Four slots: inbound for session-opening recall, store/search/delete for the durable record. Core never labels or re-renders provider output — providers own their text format (community convention e.g. devkit envelopes); core owns only timing, placement, and stability.

    Message

    Canonical conversation message. The role determines which payload is legal, making invalid role/payload combinations unrepresentable.

    Invariants:
    • SystemMessage / UserMessage never carry tool calls.
    • AssistantMessage is the ONLY variant that carries tool calls and the ONLY variant that carries reasoning.
    • ToolMessage is the ONLY variant that carries a call_id correlation and a ToolOutcome. It never carries tool calls.

    ModelCallResult

    Return value of HostRuntime::call_model and ModelPort::chat. Bundles the model completion with the processed message tree.

    The pump ALWAYS replaces the transcript body with processed_messages, then appends completion as the new assistant entry.

    ModelError

    ModelFailure

    Categorises why a model step failed (ADR §2.4). The reducer maps this to a FailureReason::Model* variant. Each variant carries a safe bounded label, never a raw provider payload.

    ModelPort

    ModelPort: LLM calling interface. Decides HOW to compact; posoco decides WHEN. chat returns ModelCallResult with processed_messages.

    Both model-side entry points receive an InvocationScope identifying the session/run the call serves (chat additionally carries the reducer- allocated effect id). Adapters that key behaviour on session identity — continuity, telemetry, cost attribution, per-session policy — read it from scope; scope-agnostic adapters ignore it. Treat it as read-only.

    NotExecutedReason

    Discriminator for the reason a tool call was NOT executed even though the model requested it (ADR §2.7).

    • UnknownTool: the requested tool name is not present in the catalog snapshot that was handed to the CallModel effect.
    • SchemaMismatch: the model's arguments parsed successfully but failed validation against the tool's input schema. The diagnostic carries a safe JSON path and type expectation; it never includes the raw argument payload.
    • RejectedByHook: a PipelineHook::before_tool returned Reject; the call was never dispatched and reason is the hook's steering text fed back to the model.

    Malformed-argument cases never reach this variant — they are ModelParseFailure at the reducer boundary (ADR §2.4).

    Observer

    Observer: read-only event listener. One event channel:

    • Agent-level (on_event / on_event_at): TurnStarted / model response / tool pending and result / TurnCompleted / TurnFailed. Agent projects these directly from committed runtime events; it never reconstructs them by scanning a terminal transcript. on_event_at additionally carries the EventScope attribution (session/run/turn); the core dispatches only the scoped variant and its default delegates here.

    The channel is read-only — never blocks or mutates. For interception or abortion, implement PipelineHook::before_model / PipelineHook::before_tool (they carry block capability via HookAbort / ToolDecision); for real-time per-effect telemetry implement PipelineHook::on_post_event — the former effect-level observer channel was folded into PipelineHook (D5/BR-7).

    OwnerId

    Stable identifier for the provider that owns a tool. The Kernel uses this only for owner lookup in the runtime registry; it does not call into the owner through this type.

    ParamType

    Value type of a command parameter. Human-friendly (unlike JSON Schema which targets LLMs). Panel/slash consumers render and parse directly from this.

    PipelineHook

    Pipeline interception points covering nearly the whole agent lifecycle: turn begin/end, before/after the model, before/after tools. Registration is unified: an extension puts self (or any implementor) into the manifest's single hooks array and overrides the methods for the points it cares about.

    • on_turn_begin: async turn-start slot. Dispatched once per run_single_turn, after the TurnStarted observer projection and before the pump's first before_model. Symmetric with on_turn_end: pre-turn failures (agent-shutdown guard, lifecycle on_start raises) dispatch nothing — the turn never began. A raise is a secondary failure — reported to observers, never failing the turn — and later hooks still run; cancellation is not a defect.
    • before_model: invoked before the model is called. Can rewrite messages or raise HookAbort. Multiple hooks form a chained rewrite pipeline — each hook sees the previous hook's output.
    • before_tool: invoked before every tool execution. Every hook is evaluated; merge precedence on ToolDecision (consent beats Reject; Defer still aborts the run until M5).
    • on_post_event: invoked after every effect completes. Read-only and non-raising — all registered hooks run in registration order; a handler cannot fail in a typed way, and a contract violation aborts loudly by design. For interception or abortion use before_model / before_tool.
    • on_post_event_at: scoped variant of on_post_event, additionally carrying the EventScope attribution of the run that committed the effect. The core dispatches ONLY this variant; its default delegates to on_post_event, so pre-scope hooks keep working unchanged.
    • on_turn_end: async turn-end slot, no payload. The terminal outcome already lives on the Observer channel — TurnCompleted / TurnFailed(String) — and an extension typically implements Observer and PipelineHook as two views over one shared struct, so a hook that needs the outcome reads its own observer-side state instead of a second payload type. Dispatched once per run_turn, after the terminal observer projection (TurnCompleted / TurnFailed) and before the call returns — on both the completed and the failed path. This is the slot for side effects that must finish by turn end, such as committing buffered writes to an external system. Implementations must carry their own timeout budget and may not block the turn's return indefinitely. Division of labor with Observer::on_event_at(TurnCompleted): the observer slot is sync, read-only and fires first; on_turn_end may await. A raise from on_turn_end is a secondary failure — reported to observers, never replacing the turn's primary outcome — and cancellation is not a defect.

    Async spread: the slots are async, so IO in an implementation body propagates through async. The pipeline dispatches hooks sequentially — each hook is awaited before the next runs — and never opens a thread or worker, so registration order is preserved at every point.

    ProviderConfig

    ProviderConfig: modelport's self-description of accepted config values. A String array (not typed enum) because each provider has its own vocabulary for e.g. reasoning_effort values.

    RateLimitInfo

    Structured quota/rate-limit verdict returned by a provider. Raised as ModelError::RateLimited instead of Transport so hosts can schedule recovery from typed data instead of parsing strings. Providers disagree on the carrier (HTTP 429, Kimi's 403, gateway JSON fields, free text), so the verdict deliberately carries no status code — only scheduling semantics.

    reset_at_ms is the Unix epoch in milliseconds at which the provider stated the quota window frees; None means no explicit reset time was stated, so waiting is not schedulable and the caller should treat the verdict as terminal for this turn. provider_code is the provider's own machine code for the verdict when available (for example z.ai "1308", OpenAI "usage_limit_reached"). message is a bounded excerpt of the provider message, safe to display.

    Reasoning

    Reasoning trace produced by the assistant (e.g. DeepSeek deepseek-reasoner chain-of-thought, OpenAI o-series reasoning content). The Kernel never interprets the content; it stores and replays it. None means the assistant produced no reasoning for this step.

    raw is a provider-defined replay payload: OpenAI Responses API reasoning items must be replayed verbatim when the host manually manages conversation state (per the official reasoning guide), so the adapter stores the raw items here. The kernel never interprets it; providers that replay plain content text (DeepSeek, Kimi, OpenAI-compatible) leave it None.

    RunId

    Stable identifier for a single Run. Supplied by the host in the Start input and carried through every state.

    RuntimeError

    Session

    A conversation session. messages is the linear transcript this thread owns; metadata is a free-form product-owned bag (used for lineage via parent_thread_id, product-specific flags, anything posoco does not interpret).

    R3 M3.7: messages carries canonical @kernel.Message values directly. There is no longer a legacy @types.Message form — the kernel ADT is the single protocol.

    SessionError

    SessionId

    Stable identifier for a product session. The Kernel never opens or materializes session storage; it carries this identity through effects, journal entries, and committed event envelopes.

    SessionStore

    SessionStore: persist and load conversation sessions by id.

    Methods use the raise style (matching ModelPort/CommandPort) instead of returning Result. This eliminates the Result-plus-async redundancy and lets js-target adapters (e.g. JsonlSessionStore) implement the trait directly instead of going through load_async/save_async side-paths.

    StreamAccumulator

    Accumulates StreamChunk events during a streaming chat call. Modelports that want a standard "double-write" implementation (one chunk to the Stream(cb) callback for telemetry, one chunk to the accumulator for the final completion) can use this helper. Modelports with more sophisticated needs (e.g. DeepSeek's dynamic tool-result removal during streaming) are free to ignore this and maintain their own state.

    R3 M3.7: the previous to_response() -> ModelResponse has been replaced by to_completion() -> @kernel.Completion. The old ModelResponse type is deleted — chat now returns ModelCallResult whose completion field is the canonical Completion.

    StreamChunk

    Canonical streaming chunk shape emitted by modelports through the Stream(cb) callback of ModelPort::chat. HostChunkCallback receives this type directly; there is no separate JSON wire contract.

    Streaming remains a host/telemetry concern, not a transcript fact (ADR §2.11).

    StreamMode

    Streaming mode for ModelPort::chat. See trait doc.

    NoStream lets the modelport skip chunk wire-format work entirely (no SSE parsing, no callback construction). Stream(cb) asks it to emit each chunk to cb as a StreamChunk. The ADR §2.11 rationale still holds — streaming is a host/telemetry concern, not a transcript fact — but the chunk contract is now the canonical StreamChunk type, not a private JSON shape.

    SystemPromptContributor

    Returns a section of the system prompt. Empty strings are skipped.

    Stability contract: the returned text MUST be byte-for-byte identical for the entire lifetime of one Agent. Posoco reads each contributor exactly once — during the first turn's first before_model call — and caches the assembled result. Dynamic signals (e.g. plan-mode toggles) MUST NOT be implemented as contributors; inject them through PipelineHook::before_model as ephemeral User messages instead.

    ToolCall

    Tool arguments as parsed JSON. This is the post-parsing representation: if the model stream produced malformed JSON, the reducer has already terminated the Run with ModelParseFailure before this type is constructed. Therefore any ToolCall reaching the scheduler has well-formed arguments, but the arguments may still fail the catalog schema (handled separately as NotExecuted(SchemaMismatch)).

    ToolDecision

    The hook's decision for a pending tool call.

    Chain semantics: every registered hook is evaluated (no short-circuit). Precedence: a raise aborts the run; Defer terminal-rejects until M5; ApproveAfterConsent beats Reject; Reject skips the call and feeds the joined reasons back to the model as a NotExecuted(RejectedByHook) tool result; otherwise the call executes with the chained rewrite. The complete chain is finalized before catalog validation and scheduling, so the final call determines its schema, owner, and execution policy. Consent binds to the exact call_id, name, and arguments returned by that consent decision; a later mutation cannot reuse it, and call_id is never rewriteable.

    ToolDef

    A single tool's definition as the model and the executor see it. Owned by the catalog snapshot (T05), not by individual providers.

    ToolName

    A tool name as it appears in the catalog (call-visible name). The Kernel uses this only for lookup in the catalog snapshot; it never re-parses or re-normalizes it.

    ToolOutcome

    Outcome of a single tool invocation (ADR §2.4, §2.11).

    Variants:
    • Success: tool ran and returned structured data. content is the plain-text result sent to the model by the built-in adapters and used for transcripts. structured is an optional machine-readable payload preserved in the kernel transcript, TurnEvent::ToolCallResult, and session snapshots for observers/UIs; the built-in adapters do not send it to the model. Structured objects follow a reserved summary : String field convention for one-line UI summaries.
    • ToolReportedError: tool ran and reported a business-level failure. The Run continues; the failure is fed back to the model as a normal tool message.
    • RuntimeFailure: tool did not run to completion — adapter raised a RuntimeError. Still non-terminal for the Run.
    • NotExecuted: model requested a tool that was not executed (unknown name, parseable-but-schema-invalid args, or a before_tool hook rejection). The original call id is preserved so the model can correlate the feedback.

    There is deliberately no boolean is_error field here. The variant tag IS the error status.

    ToolProvider

    ToolProvider: declare tools via list_tools(), execute via execute().

    R3 M3.7: signatures use canonical kernel types directly. ToolDef carries owner + policy at the catalog level; list_tools returns the kernel ToolDef minus those (they are filled in by the catalog builder, since they depend on the agent's tool-routing configuration, not on the tool itself). execute returns ToolOutcome so business errors, runtime errors, and not-executed cases are all distinguishable without a boolean.

    Transcript

    Canonical conversation transcript. Every transition produces a fresh array (the reducer never mutates caller-owned arrays, ADR §2.10). The reducer is the only writer.

    TurnEvent

    Turn lifecycle events observed via Observer::on_event. R3 M3.7: payload types are now canonical kernel types (ToolCall, ToolOutcome, Message, Usage). is_error on ToolCallResult is preserved for legacy observer compatibility — it is derived from the ToolOutcome variant (is_failure).

    TurnId

    Identifier for a single turn within a Run. A Run may contain multiple turns (initial prompt + follow-ups); turns never outlive their Run. follow_up allocates a new TurnId under the same RunId.

    TurnResult

    Return value of Agent::run_turn. R3 M3.7: payload types are canonical.

    UiBody

    Structured payload a render intent carries. Host decides layout and color.

    UiDescriptor

    Collected at composition time: the host aggregates every UiPort's descriptors to know which autocomplete triggers to honor.

    UiEntry

    One keyed entry of an Entries body. color carries a semantic role name from the host-negotiated vocabulary, never a raw color value.

    UiError

    Errors raised by UiPort implementations. UiPort::request MUST raise one of these typed variants instead of an untyped error, so that callers can distinguish "user cancelled" from "this UI doesn't support requests".

    UiHint

    Hint to a panel consumer about what UI action to take after invoke. Slash consumers ignore this. Fixed enum + None fallback; ext long-tail needs go through CommandDef.metadata.

    UiPort

    The UI extension contract. Implemented by host packages (cetas-js, cetas-native) or testkit.

    UiRender

    One render intent. Same key replaces a prior intent in the same UiSlot.

    UiRequest

    User interaction primitives. Hosts that lack a variant raise UiError::Unsupported.

    UiResponse

    Response to a UiRequest. Cancelled = user dismissed; no UI = UiError::Unsupported.

    UiSlot

    Where a render intent lands: Status (single-line bar), Notice (transient toast), Widget (persistent region).

    Usage

    Token usage reported by the model for a single step. The Kernel only counts what is actually reported; absent dimensions are None, never silently coerced to 0 (ADR §2.9 — unknown usage is never reported as 0).

    Agent

    pub struct Agent {
    // private fields
    }

    Public deep module for agent developers. The runtime representation is a private handle so canonical execution machinery never enters the interface.

    Agent::Agent

    Construct an Agent from an array of extensions. Aggregation order is the array order; collisions fail-fast. At least one extension must contribute a ModelPort.

    ui_projection (default false, BR-5): when true, Posoco's built-in render policy (UiRenderHook over the aggregated UiPort) is appended to the hook chain. Hosts with their own UI policy leave it off and register their own PipelineHook instead.

    Agent::commands

    Agent::control

    fn Agent::control(self : Agent) -> AgentControl

    The Agent's control handle (experimental runtime seam). Advanced hosts use it to abort the active run or to submit follow-up messages that the Agent consumes at turn boundaries. The handle is identity-guarded: submissions against a stale or absent run are rejected at enqueue time.

    Agent::shutdown

    async fn Agent::shutdown(self : Agent) -> Unit

    Agent::with_runtime

    Advanced constructor (experimental runtime seam): same Agent, same default assembly, but the caller supplies the effect-execution runtime. runtime is typically a wrapper around @runtime.PortRuntime that overrides only the methods it needs (e.g. execute_tool + cancel_effects for cancellation propagation). Plain extension authors do not need this — see docs/RUNTIME.md.

    catalog_source (optional): when supplied, the source owns the tool catalog — Posoco reads it at construction and re-reads it at each prompt boundary whose revision() changed, swapping the refreshed snapshot in for subsequent runs while in-flight runs keep their version. Definitions are taken verbatim (owner/policy respected). Without it, the catalog is the static snapshot of the aggregated ToolProvider declarations.

    AgentConfig

    pub struct AgentConfig {
    max_tool_rounds : Int?
    temperature : Double?
    max_output_tokens : Int?
    model_context_window : Int?
    compact_threshold : Double?
    system_prompt : String?
    memory_inbound_timeout_ms : Int?
    }

    Agent-level runtime config: provider-agnostic tuning and run budget. max_tool_rounds bounds the tool rounds of one turn: Some(n) allows n full rounds and rejects the (n+1)-th batch atomically; Some(0) forbids tool execution; None is unbounded (the recommended product default — the human abort and compaction govern loop length, matching peer coding agents).

    AgentControl

    pub struct AgentControl {
    // private fields
    }

    AgentControl::abort_active

    Request cancellation of the active run. Repeated requests return the displayable AbortAlreadyRequested outcome.

    AgentControl::active_run_id

    Currently-active run id, or None while the Agent is idle.

    AgentControl::active_turn_id

    Currently-active turn id, or None while the Agent is idle.

    AgentControl::enqueue_follow_up

    Submit a follow-up consumed by the Agent at a turn boundary. A stale or absent run is rejected so a late task cannot target a later run.

    AgentControl::pending_follow_ups

    fn AgentControl::pending_follow_ups(self : AgentControl) -> Int

    Number of follow-ups waiting for the Agent's next turn boundary.

    CompositeToolProvider

    pub(all) struct CompositeToolProvider {
    providers : Array[&
    ToolProvider
    ]
    }

    Internal: merges multiple providers' tool lists. Agent routes via ToolRouting instead.

    CompositeUiPort

    pub(all) struct CompositeUiPort {
    contributors : Array[&
    UiPort
    ]
    }

    Fan-out UiPort: render delivers to every contributor; request tries each in order, first non-Unsupported wins.

    CompositeUiPort::CompositeUiPort

    CompositeUiPort::contributors

    CompositeUiPort::render

    CompositeUiPort::ui_descriptor

    HookRecord

    pub(all) struct HookRecord {
    label : String
    outcome : RecordedHookOutcome
    } derive(
    Debug
    )

    A recorded hook invocation: which hook function ran and its decision.

    ManifestOnly

    pub(all) struct ManifestOnly {
    cached :
    ExtensionManifest

    }

    Anonymous Extension wrapper around a pre-built ExtensionManifest.

    Real extensions implement Extension directly on their struct (so the compiler knows the concrete type satisfies every port it contributes). But tests, prototypes, and one-off agents often want to compose ports without defining a fresh named struct. ManifestOnly fills that role: build a manifest with tk_ext, wrap it, and pass it to Agent::new as a &Extension.

    This is a testkit-only convenience. Production agents should expose a typed <ext>_extension() factory that returns an ExtensionManifest from a real struct, not use ManifestOnly.

    ManifestOnly::ManifestOnly

    Construct an anonymous Extension from a pre-built manifest.

    ManifestOnly::extension_id

    fn ManifestOnly::extension_id(self : ManifestOnly) -> String

    Expose Extension methods on ManifestOnly for dot-syntax callers and so &ManifestOnly can be coerced to &Extension.

    ManifestOnly::manifest

    Expose Extension methods on ManifestOnly for dot-syntax callers and so &ManifestOnly can be coerced to &Extension.

    NoopCommandPort

    pub(all) struct NoopCommandPort {
    }

    No-op CommandPort: declares no commands, invoke always raises NotFound. Useful as a default or for testing.

    NoopCommandPort::commands

    NoopLifecycle

    pub(all) struct NoopLifecycle {
    }

    NoopUiPort

    pub(all) struct NoopUiPort {
    }

    Default UiPort for headless hosts. request raises Unsupported.

    NoopUiPort::NoopUiPort

    fn NoopUiPort::NoopUiPort() -> NoopUiPort

    NoopUiPort::render

    fn NoopUiPort::render(_self : NoopUiPort, _intent :
    UiRender
    ) -> Unit

    NoopUiPort::ui_descriptor

    RecordedHookOutcome

    pub(all) enum RecordedHookOutcome {
    PassedThrough
    AbortedWith(String)
    DeferredWith(String)
    } derive(
    Debug
    )

    The exact decision made by a RecordingHook invocation.

    RecordingHook

    pub(all) struct RecordingHook {
    records : Array[HookRecord]
    abort_on : Map[String, String]
    defer_on : Map[String, String]
    }

    Multi-hook fake that implements PipelineHook at all three interception points. By default it is a pass-through (returns messages unchanged, approves tool calls). Records every call.

    RecordingHook::RecordingHook

    fn RecordingHook::RecordingHook() -> RecordingHook

    RecordingHook::abort_when

    fn RecordingHook::abort_when(self : RecordingHook, trigger : String, msg : String) -> Unit

    Configure the hook to abort/reject when the label contains trigger.

    RecordingHook::defer_when

    fn RecordingHook::defer_when(self : RecordingHook, trigger : String, reason : String) -> Unit

    Configure the hook to defer when the label contains trigger.

    RecordingHook::records

    RecordingLifecycle

    pub(all) struct RecordingLifecycle {
    id : String
    log : Array[String]
    seen_model : Bool
    seen_ui : Bool
    }

    RecordingLifecycle — records Lifecycle phase invocations for post-hoc assertions. Log entries are "<id>:compose(model=<Bool>,ui=<Bool>)", "<id>:start", "<id>:shutdown" in call order. Share one Array[String] across several fakes (and other recorders writing the same log) to assert interleaving and ordering.

    RecordingLifecycle::RecordingLifecycle

    fn RecordingLifecycle::RecordingLifecycle(id~ : String, log~ : Array[String]) -> RecordingLifecycle

    RecordingLifecycle::on_compose

    Expose Lifecycle methods on RecordingLifecycle for dot-syntax callers.

    RecordingLifecycle::on_shutdown

    async fn RecordingLifecycle::on_shutdown(self : RecordingLifecycle) -> Unit

    Expose Lifecycle methods on RecordingLifecycle for dot-syntax callers.

    RecordingLifecycle::on_start

    async fn RecordingLifecycle::on_start(self : RecordingLifecycle) -> Unit

    Expose Lifecycle methods on RecordingLifecycle for dot-syntax callers.

    RecordingObserver

    pub(all) struct RecordingObserver {
    events : Array[
    TurnEvent
    ]
    }

    Observer fake that records every TurnEvent in arrival order. Unlike a counter, it keeps the full sequence for trace assertions.

    RecordingObserver::RecordingObserver

    fn RecordingObserver::RecordingObserver() -> RecordingObserver

    RecordingObserver::events

    Snapshot of all recorded events in order.

    RecordingObserver::on_event

    RecordingSessionStore

    pub(all) struct RecordingSessionStore {
    store : Map[String,
    Session
    ]
    load_failures : Map[String,
    SessionError
    ]
    save_failures : Array[String]
    ops : Array[SessionOp]
    }

    SessionStore fake that keeps an in-memory map and records every load/save with the metadata snapshot at save time. Load/save failures are configurable per id.

    RecordingSessionStore::RecordingSessionStore

    fn RecordingSessionStore::RecordingSessionStore() -> RecordingSessionStore

    RecordingSessionStore::fail_load

    Configure load to fail for a given id.

    RecordingSessionStore::fail_save

    fn RecordingSessionStore::fail_save(self : RecordingSessionStore, id : String) -> Unit

    Configure save to fail for a given id (all subsequent saves to that id).

    RecordingSessionStore::ops

    Recorded operations in order.

    RecordingSessionStore::save

    RecordingSessionStore::save_snapshots

    fn RecordingSessionStore::save_snapshots(self : RecordingSessionStore) -> Array[(String, Map[String, Json])]

    All save operations, in order, as (id, metadata_snapshot) pairs.

    RecordingSessionStore::seed

    Seed a session id with an existing session (including metadata).

    RecordingToolProvider

    pub(all) struct RecordingToolProvider {
    tool_defs : Array[
    ToolDef
    ]
    outcomes : Map[String, ScriptedToolOutcome]
    list_calls : Int
    exec_records : Array[ToolExecRecord]
    ops : Array[ToolProviderOp]
    }

    ToolProvider fake that declares a fixed tool list and routes execute() by tool name through a configurable outcome map. Unknown tools raise RuntimeError::UnknownTool. Every execute is recorded with its call id.

    RecordingToolProvider::RecordingToolProvider

    RecordingToolProvider::exec_records

    Snapshot of recorded executions (call id, tool name, outcome).

    RecordingToolProvider::execute_direct

    Direct (non-trait) execute entry point for testkit self-tests.

    RecordingToolProvider::list_call_count

    fn RecordingToolProvider::list_call_count(self : RecordingToolProvider) -> Int

    Number of times list_tools was called.

    RecordingToolProvider::list_tools

    RecordingToolProvider::ops

    Snapshot of list/execute operations in their exact observed order.

    RecordingUiPort

    pub(all) struct RecordingUiPort {
    rendered : Array[
    UiRender
    ]
    scripted_responses : Array[
    UiResponse
    ]
    unsupported_mode : Bool
    descriptor :
    UiDescriptor

    }

    RecordingUiPort::RecordingUiPort

    Construct with a list of scripted responses. They will be returned in order, one per request call.

    RecordingUiPort::new_unsupported

    fn RecordingUiPort::new_unsupported() -> RecordingUiPort

    Construct a UiPort that always raises Unsupported from request.

    RecordingUiPort::render

    Expose UiPort methods on RecordingUiPort for dot-syntax callers.

    RecordingUiPort::render_count

    fn RecordingUiPort::render_count(self : RecordingUiPort) -> Int

    Number of render calls received.

    RecordingUiPort::rendered_in_slot

    Filter rendered intents by slot. Useful for asserting "exactly one Notice was emitted" without caring about other slots.

    RecordingUiPort::rendered_intents

    All render intents received, in arrival order. The returned array is a copy; mutating it does not affect the recording.

    RecordingUiPort::request

    Expose UiPort methods on RecordingUiPort for dot-syntax callers.

    RecordingUiPort::set_descriptor

    fn RecordingUiPort::set_descriptor(self : RecordingUiPort, descriptor :
    UiDescriptor
    ) -> Unit

    Set the descriptor returned by ui_descriptor.

    RecordingUiPort::ui_descriptor

    Expose UiPort methods on RecordingUiPort for dot-syntax callers.

    RecordingUiPort::unconsumed_response_count

    fn RecordingUiPort::unconsumed_response_count(self : RecordingUiPort) -> Int

    Number of request calls that have NOT yet been answered (i.e. consumed a scripted response). Useful to assert that no extra requests happened.

    ScopeRecordingModel

    ModelPort fake that records the InvocationScope received by every chat/compact call. Chat behaviour is scripted like ScriptedModel; compact returns compact_result when set, otherwise raises ResponseParse (same contract as ScriptedModel). Use it to pin the scope-flow contract end to end.

    ScopeRecordingModel::ScopeRecordingModel

    ScopeRecordingModel::chat_scopes

    Scopes observed by chat, in call order.

    ScopeRecordingModel::compact_scopes

    Scopes observed by compact, in call order.

    ScriptedMemoryPort

    pub(all) struct ScriptedMemoryPort {
    inbounds : Array[String?]
    index : Int
    calls : Int
    sessions : Array[String]
    requests : Array[String]
    stored : Array[(String, Map[String, Json])]
    deleted : Array[String]
    search_script : Array[String?]
    search_index : Int
    searches : Array[(String, Int?)]
    next_ticket : Int
    }

    Scripted MemoryPort for agent-level tests of the port contract. Inbound side: inbounds entries are returned in call order as the full inbound body (a None entry plays an empty read); once the script is exhausted every later call returns None. Core calls inbound at most once per session lifetime per process and ONLY on the session's first turn, so one entry per session is the natural scripting granularity — call_count, received_sessions, and received_requests are the assertion surface for the once-per-session, first-turn-only, and request-text guarantees. Storage side: store records (content, metadata) and hands back synthetic tickets (scripted-0, scripted-1, ...); delete records the id; search_script entries are returned in call order (a None entry plays a no-hit search; exhausted -> None) and every call records its (query, top_k)received_stores, received_deletes, and received_searches are the write/read assertion surfaces.

    ScriptedMemoryPort::ScriptedMemoryPort

    fn ScriptedMemoryPort::ScriptedMemoryPort(inbounds~ : Array[String?], search_script? : Array[String?]) -> ScriptedMemoryPort

    ScriptedMemoryPort::call_count

    fn ScriptedMemoryPort::call_count(self : ScriptedMemoryPort) -> Int

    Number of inbound calls observed so far.

    ScriptedMemoryPort::inbound

    async fn ScriptedMemoryPort::inbound(self : ScriptedMemoryPort, session_id~ : String, request~ : String) -> String? raise
    MemoryError

    ScriptedMemoryPort::received_deletes

    fn ScriptedMemoryPort::received_deletes(self : ScriptedMemoryPort) -> Array[String]

    Ids delete was called with, in call order.

    ScriptedMemoryPort::received_requests

    fn ScriptedMemoryPort::received_requests(self : ScriptedMemoryPort) -> Array[String]

    Request texts inbound was called with, in call order.

    ScriptedMemoryPort::received_searches

    fn ScriptedMemoryPort::received_searches(self : ScriptedMemoryPort) -> Array[(String, Int?)]

    (query, top_k) pairs search was called with, in call order.

    ScriptedMemoryPort::received_sessions

    fn ScriptedMemoryPort::received_sessions(self : ScriptedMemoryPort) -> Array[String]

    Session ids inbound was called with, in call order.

    ScriptedMemoryPort::received_stores

    fn ScriptedMemoryPort::received_stores(self : ScriptedMemoryPort) -> Array[(String, Map[String, Json])]

    (content, metadata) pairs store was called with, in call order.

    ScriptedModel

    pub(all) struct ScriptedModel {
    steps : Array[ScriptedModelStep]
    index : Int
    calls : Int
    received_messages : Array[Array[
    Message
    ]]
    received_tools : Array[Array[
    ToolDef
    ]]
    received_options : Array[
    ChatOptions
    ]
    received_chunks : Array[
    StreamChunk
    ]
    }

    ModelPort fake that plays a fixed script. Each chat call consumes one step in order. When the script is exhausted the next call fails with ModelError::Transport("scripted_model_exhausted at call <N>") — it never silently repeats the last response.

    ScriptedModel::ScriptedModel

    fn ScriptedModel::ScriptedModel(steps : Array[ScriptedModelStep]) -> ScriptedModel

    ScriptedModel::call_count

    fn ScriptedModel::call_count(self : ScriptedModel) -> Int

    Number of chat calls observed so far.

    ScriptedModel::chat_direct

    Direct (non-trait) chat entry point for testkit self-tests. Trait methods on concrete types must be dispatched through a &ModelPort reference; this wrapper lets tests call chat without an Agent.

    ScriptedModel::options_received

    Snapshot of all chat options received, in call order.

    ScriptedModelStep

    A single scripted model interaction. R3 M3.7: each step produces a ModelCallResult (the canonical chat return type) instead of the deleted ModelResponse. Stream carries the chunk sequence plus the final completion; chunks are emitted to the callback during the call.

    ScriptedToolOutcome

    Configurable outcome for a tool call: success outcome, or raised runtime error. Constructors are prefixed with Outcome to avoid ambiguity with Result::Ok/Result::Err at unqualified call sites.

    SessionOp

    pub(all) enum SessionOp {
    OpLoad(id~ : String, result~ : Result[
    Session
    ,
    SessionError
    ])
    OpSave(id~ : String, metadata~ : Map[String, Json], result~ : Result[Unit,
    SessionError
    ])
    OpAppend(id~ : String, from_index~ : Int, messages~ : Array[
    Message
    ], result~ : Result[Unit,
    SessionError
    ])
    OpTruncate(id~ : String, from_index~ : Int, result~ : Result[Unit,
    SessionError
    ])
    } derive(
    Debug
    )

    A recorded session operation. Constructors are prefixed with Op to avoid ambiguity with SessionError::Load/SessionError::Save.

    SystemPromptHook

    pub(all) struct SystemPromptHook {
    base_prompt : String
    contributors : Array[SystemPromptSection]
    assembled : String?
    }

    Combines a fixed base prompt with contributor sections and prepends the result as a single System message at index 0. Empty sections are skipped.

    The assembled prompt is cached lazily on the first call to before_model. Laziness matters because extension Lifecycle::on_start callbacks run inside the first run_single_turn but before the first before_model, so contributors whose state is initialized in on_start must be read after that point. After caching, the text is frozen for the lifetime of the hook.

    SystemPromptHook::SystemPromptHook

    fn SystemPromptHook::SystemPromptHook(base_prompt~ : String, contributors~ : Array[SystemPromptSection]) -> SystemPromptHook

    SystemPromptHook::assemble

    fn SystemPromptHook::assemble(self : SystemPromptHook) -> String

    Assemble base + non-empty contributor sections with id: headers. Sections are separated by a blank line (\n\n). The result is empty when the base prompt and every contributor return empty strings.

    SystemPromptSection

    pub(all) struct SystemPromptSection {
    id : String
    contributor : &
    SystemPromptContributor

    }

    A contributor entry: manifest id (for section header) and the contributor.

    SystemPromptSection::SystemPromptSection

    ToolExecRecord

    pub(all) struct ToolExecRecord {
    requested_name : String
    call_id : String
    tool_name : String
    arguments : Json
    outcome : ScriptedToolOutcome
    } derive(
    Debug
    )

    A recorded tool execution: call id, tool name, and outcome.

    ToolProviderOp

    pub(all) enum ToolProviderOp {
    ListTools
    ExecuteTool(requested_name~ : String, call~ :
    ToolCall
    )
    } derive(
    Debug
    )

    Unified provider operation trace, preserving list/execute interleaving.

    ToolRegistry

    Dynamic runtime tool registration. Implements ToolProvider.

    ToolRegistry::ToolRegistry

    fn ToolRegistry::ToolRegistry() -> ToolRegistry

    ToolRegistry::register

    Register (or deliberately replace) a tool at runtime. Re-registering an existing name overwrites both the definition and the executor — that is intentional hot-replacement semantics for dynamic registries. Callers that did NOT mean to replace should use register_strict, which fails fast on a name collision instead of silently shadowing the prior tool.

    ToolRegistry::register_strict

    Register a tool, failing fast with RuntimeError::ToolAlreadyRegistered when the name is already taken. This mirrors the composition-time rule (M0-T06-B): a name collision is never silently resolved by last-wins.

    ToolRegistry::unregister

    fn ToolRegistry::unregister(self : ToolRegistry, name : String) -> Unit

    UiRenderHook

    pub(all) struct UiRenderHook {
    ui : &
    UiPort

    }

    UiRenderHook: PipelineHook (on_post_event) that projects events into UI render intents. Non-raising by contract (both PipelineHook::on_post_event and UiPort::render are non-raising), so there is nothing to propagate.

    UiRenderHook::UiRenderHook

    UiRenderHook::on_post_event

    async fn UiRenderHook::on_post_event(self : UiRenderHook, stage :
    HookStage
    ) -> Unit noraise

    MEMORY_INBOUND_LEAD

    let MEMORY_INBOUND_LEAD : String

    Fixed lead line core puts in front of the injected memory message — the one piece of memory-specific text core ever produces. Everything past this line is provider content, untouched by core.

    agent_config

    fn agent_config(max_tool_rounds? : Int?, temperature? : Double?, max_output_tokens? : Int?, model_context_window? : Int?, compact_threshold? : Double?, system_prompt? : String?, memory_inbound_timeout_ms? : Int?) -> AgentConfig

    Construct an AgentConfig naming only the knobs you set; every omitted knob is None. Prefer this over record literals so new optional fields never break construction sites.

    assert_events_contain

    fn assert_events_contain(haystack : Array[
    TurnEvent
    ], needle : Array[
    TurnEvent
    ]) -> Unit

    Assert the recorded events contain a sub-sequence matching needle, in order. Reports the first needle event that could not be matched.

    assert_events_eq

    Assert two event traces are equal, reporting the first differing index, the expected and actual event, when they diverge.

    event_trace_mismatch

    fn event_trace_mismatch(expected : Array[
    TurnEvent
    ], actual : Array[
    TurnEvent
    ]) -> String?

    Return the first structural trace mismatch. Kept pure so conformance tests can prove missing, duplicate, and out-of-order detection without catching an assertion panic.

    parse_slash_args

    fn parse_slash_args(text : String, params : Array[
    CommandParam
    ]) -> Result[Json, String]

    Parse a slash argument string into a JSON object according to CommandParam definitions. Positional params fill in declaration order; non-positional params expect key=value form. Applies defaults for missing optional params. Returns Err with a reason string on type conversion failure or missing required params. Pure function (no IO).

    tk_config

    fn tk_config() -> AgentConfig

    Build a default universal AgentConfig for scripted tests.

    tk_error_result

    fn tk_error_result(content : String) ->
    ToolOutcome

    Build a business-error ToolOutcome (provider reports a tool-level failure the model should see, but the run continues).

    tk_ext

    Build a ManifestOnly extension from labeled optional arguments.

    Every port argument defaults to empty; pick the ones the test needs. The model parameter is &ModelPort? because at most one model is allowed per agent — Some(m) puts it in the manifest's models array, None leaves models empty (use a different extension to contribute the model).

    Example:
    let model = ScriptedModel(..)
    let tools = RecordingToolProvider(..)
    let store = RecordingSessionStore()
    let observer = RecordingObserver()
    let agent = Agent(
    exts=[
    tk_ext("model", model=Some(model)),
    tk_ext("tools", tools=[tools]),
    tk_ext("io", sessions=[store], observers=[observer]),
    ],
    config=tk_config(),
    )

    tk_ok_result

    fn tk_ok_result(content : String) ->
    ToolOutcome

    Build a successful ToolOutcome.

    tk_scope

    Fabricated InvocationScope for tests that drive a ModelPort directly (no reducer in play, so effect_id is None).

    tk_stop_response

    fn tk_stop_response(text : String) ->
    ModelCallResult

    Build a ModelCallResult whose completion is a stop-finish text response with no tool calls. The processed_messages is None, signalling to the pump that the modelport did no preprocessing and the transcript should be left as-is.

    Important: because ModelCallResult requires processed_messages to be a concrete array (not Optional), the default @runtime.PortRuntime is responsible for filling it with the input messages when the modelport did not preprocess. Tests that drive ScriptedModel directly through the agent pipeline go through PortRuntime, which substitutes the actual input messages when processed_messages is empty. See PortRuntime::call_model for the substitution logic.

    tk_system_msg

    fn tk_system_msg(text : String) ->
    Message

    Build a system message.

    tk_tool_call_response

    fn tk_tool_call_response(tool_name : String, call_id : String, args : Json) ->
    ModelCallResult

    Build a ModelCallResult whose completion requests a single tool call.

    tk_tool_def

    fn tk_tool_def(name : String, description : String) ->
    ToolDef

    Build a ToolDef with an empty object schema. Owner is a placeholder — Agent::build_catalog derives the catalog owner at composition time. Policy is Parallel (the declared default; composition honors it as-is). Provenance is None.

    tk_user_msg

    fn tk_user_msg(text : String) ->
    Message

    Build a user message.

    tk_view

    Build a CompositionView for unit-testing Lifecycle::on_compose implementations: requires plays the role of the fake manifest's declaration, and the composed ports stand in for what the composition would have delivered.

    validate_args

    fn validate_args(def :
    CommandDef
    , args : Json) -> Result[Json, String]

    Validate a JSON object of args against a CommandDef's params. Checks required presence; optional params with no value and no default are omitted (not an error). Returns the (possibly defaulted) args on Ok. Pure function (no IO).