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.11.1
Download zip
Author
Version
0.11.1
License
Apache-2.0
Last updated
10 hours ago
Downloads
125

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

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

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

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

#
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). All slots are async so adapter IO propagates through async; synchronous implementations write plain fn bodies (a sync body satisfies an async slot).

#
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.
  • 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 (the HTTP 429 family). Raised as ModelError::RateLimited instead of Transport so hosts can schedule recovery from typed data instead of parsing strings.

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

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

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

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

#
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

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?
compact_threshold : Double?
}

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: PipelineHook (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.

#
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

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

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

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