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
moon add colmugx/posoco@0.10.1
Download zip
Author
Version
0.10.1
License
Apache-2.0
Last updated
9 days ago
Downloads
115

Dependencies

README

#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.9.0

#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: [],
}
}

///|
/// 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.

#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 --target native --output-json moon test --target native --output-json moon fmt moon info

#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.

#
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.

#
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).

#
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.

#
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.

#
Hook

using @colmugx/posoco/port { trait Hook }

Pipeline interception points. 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.

  • 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. Approve (with optional rewrite), Defer, or Reject. First non-Approve decision wins.
  • 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.

#
HookAbort

Raised by Hook::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.

#
Lifecycle

Lifecycle: resource cleanup hook. Called once on agent shutdown to release connections, flush buffers, etc. async to allow IO during cleanup.

#
MemoryEntry

#
MemoryError

#
MemoryPort

MemoryPort: durable cross-session memory store/search/delete. Distinct from SessionStore (conversation history) — memory holds durable decisions/knowledge (e.g. brain.md integration).

#
MemoryQuery

#
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.

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): 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.

The channel is read-only — never blocks or mutates. For interception or abortion, implement Hook::before_model / Hook::before_tool (they carry block capability via HookAbort / ToolHookDecision); for real-time per-effect telemetry implement Hook::on_post_event — the former effect-level observer channel was folded into Hook (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.

#
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.

#
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

Streaming chunk shape emitted by modelports inside the Stream(cb) callback of ModelPort::chat. This is the modelport's own representation of provider chunks; posoco does NOT define a canonical chunk type (ADR §2.11 — streaming is host/telemetry concern, not transcript fact).

StreamChunk exists as a public type because it is useful for modelports that want a shared wire vocabulary (ext-llm and ext-deepseek both emit these). Modelports that prefer their own chunk type are free to use it; the only contract is "what you pass to the Stream(cb) callback, the HostChunkCallback consumer must be able to decode".

#
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 raw JSON — posoco does NOT define a canonical chunk type (ADR §2.11: streaming is host/telemetry concern, not transcript fact), so the JSON shape is a private contract between the modelport and the HostChunkCallback consumer.

#
SystemPromptContributor

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

#
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)).

#
ToolDef

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

#
ToolHookDecision

The hook's decision for a pending tool call.

#
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. structured is the model-facing JSON payload; content is the human-readable mirror used by the transcript.
  • 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 or parseable-but-schema-invalid args). 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).

#
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.

#
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 Hook instead.

#
Agent::commands

#
Agent::control

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(all) struct AgentConfig {
max_tool_rounds : Int?
temperature : Double?
max_output_tokens : Int?
model_context_window : 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).

#
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 : RecordedHookOutcome2
}

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.

#
MemoryRetrievalHook

pub(all) struct MemoryRetrievalHook {
memories : Array[&
MemoryPort
]
top_k : Int
on_failure : (String) -> Unit
}

MemoryRetrievalHook: Hook (before_model) that retrieves durable memory and injects as a SystemMessage (after the system-prompt). Best-effort, non-fatal — a search failure never aborts the turn; messages pass through unchanged and the failure is reported through on_failure (Agent wires this to the Custom(source="posoco.core",label="secondary_failure") observer event).

#
MemoryRetrievalHook::MemoryRetrievalHook

fn MemoryRetrievalHook::MemoryRetrievalHook(memories~ : Array[&
MemoryPort
], top_k? : Int, on_failure? : (String) -> Unit) -> MemoryRetrievalHook

#
NoopCommandPort

pub(all) struct NoopCommandPort {
}

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

#
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

#
RecordedHookOutcome2

pub(all) enum RecordedHookOutcome2 {
PassedThrough
AbortedWith(String)
DeferredWith(String)
}

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

#
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.

#
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_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::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.

#
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

pub(all) enum ScriptedToolOutcome {
OutcomeOk(
ToolOutcome
)
OutcomeErr(
RuntimeError
)
}

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
])
}

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]
}

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

#
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.

#
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
}

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

#
ToolProviderOp

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

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: Hook (on_post_event) that projects events into UI render intents. Non-raising by contract (both Hook::on_post_event and UiPort::render are non-raising), so there is nothing to propagate.

#
UiRenderHook::UiRenderHook

#
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

fn tk_scope(session_id? : String, run_id? : String) ->
InvocationScope

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/policy are placeholders — Agent::build_catalog overwrites them at composition time. Provenance is None.

#
tk_user_msg

fn tk_user_msg(text : String) ->
Message

Build a user message.

#
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).