README

colmugx/posoco/port does not have a README file

#
CommandPort

pub(open) trait CommandPort {
fn commands(Self) -> Array[CommandDef]
async fn invoke(Self, id : String, args : Json) -> CommandOutcome raise
CommandError

}

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.

#
Extension

pub(open) trait Extension {
fn extension_id(Self) -> String
fn manifest(Self) -> ExtensionManifest
}

#
Lifecycle

pub(open) trait Lifecycle {
fn on_compose(Self, ctx : CompositionView) -> Unit raise
CompositionError
= _
async fn on_start(Self) -> Unit = _
async fn on_shutdown(Self) -> Unit
}

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

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

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

#
Observer

pub(open) trait Observer {
fn on_event(Self, event :
TurnEvent
) -> Unit = _
fn on_event_at(Self, scope :
EventScope
?, event :
TurnEvent
) -> Unit = _
}

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

#
PipelineHook

pub(open) trait PipelineHook {
async fn before_model(Self, messages : Array[
Message
]) -> Array[
Message
] raise HookAbort = _
async fn before_tool(Self, call :
ToolCall
) -> ToolDecision = _
async fn on_post_event(Self, stage : HookStage) -> Unit noraise = _
async fn on_post_event_at(Self, scope :
EventScope
?, stage : HookStage) -> Unit noraise = _
async fn on_turn_begin(Self) -> Unit = _
async fn on_turn_end(Self) -> Unit = _
}

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.

#
SessionStore

pub(open) trait SessionStore {
async fn load(Self, id : String) ->
Session
raise
SessionError

async fn save(Self, id : String, session :
Session
) -> Unit raise
SessionError

}

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.

#
SystemPromptContributor

pub(open) trait SystemPromptContributor {
fn system_prompt(Self) -> String
}

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

#
ToolProvider

pub(open) trait ToolProvider {
fn list_tools(Self) -> Array[
ToolDef
]
async fn execute(Self, name : String, call :
ToolCall
) ->
ToolOutcome
raise
RuntimeError

}

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.

#
UiPort

pub(open) trait UiPort {
fn ui_descriptor(Self) -> UiDescriptor
fn render(Self, intent : UiRender) -> Unit
async fn request(Self, req : UiRequest) -> UiResponse raise
UiError

}

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

#
HookAbort

pub(all) suberror HookAbort {
Aborted(reason~ : String)
}

Raised by PipelineHook::before_model to stop the run before the model is called.
impl Show for HookAbort

#
AutocompleteItem

pub(all) struct AutocompleteItem {
label : String
insert_text : String
detail : String?
kind : String
} derive(
Debug
)

#
AutocompleteSource

pub(all) struct AutocompleteSource {
trigger : String
kind : String
fetch : (String) -> Array[AutocompleteItem]
} derive(
Debug
)

A source of autocomplete suggestions tied to a trigger character.

#
Capability

pub(all) enum Capability {
Model
Ui
} derive(Eq,
Debug
)

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.

#
CommandDef

pub(all) struct CommandDef {
id : String
label : String
description : String
category : String
ctype : CommandType
params : Array[CommandParam]
aliases : Array[String]
shortcut : String?
icon : String?
visible : Bool
metadata : Json?
} derive(Eq,
Debug
)

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.

#
CommandOutcome

pub(all) enum CommandOutcome {
Success(feedback~ : String, structured~ : Json?, ui_hint~ : UiHint?)
Failure(reason~ : String)
NeedsInput(prompt~ : String)
} derive(Eq,
Debug
)

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

pub(all) struct CommandParam {
name : String
label : String
description : String
ptype : ParamType
required : Bool
default : Json?
choices : Array[String]?
positional : Bool
} derive(Eq,
Debug
)

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

#
CommandType

pub(all) enum CommandType {
Action
Toggle
Select
Input
MultiStep
} derive(Eq,
Debug
)

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

#
CompositionView

pub struct CompositionView {
// private fields
}

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.

#
CompositionView::model

The single composed ModelPort — present only when the extension declared Capability::Model in its manifest's requires. This is the unique outlet that passed the composition cardinality gate, source-agnostic (the extension cannot tell a plain provider from a routing meta-extension, and must not).

#
CompositionView::resolve

fn CompositionView::resolve(requires~ : Array[Capability], model~ : &ModelPort, ui~ : &UiPort) -> CompositionView

Gate a view on a manifest's declared requires: a declared capability is populated from the composed ports, an undeclared one reads None. Called by the composition after every gate has passed, so model and ui are guaranteed to be the final composed values.

#
CompositionView::ui

The composed UiPort (single contributor, CompositeUiPort, or NoopUiPort) — present only when the extension declared Capability::Ui. Human-interaction requests (UiRequest::Confirm / Select / Input) go here; do not occupy the ui manifest slot just to consume the composed UI.

#
ExtensionManifest

pub(all) struct ExtensionManifest {
id : String
models : Array[&ModelPort]
tools : Array[&ToolProvider]
sessions : Array[&SessionStore]
observers : Array[&Observer]
hooks : Array[&PipelineHook]
memory : Array[&MemoryPort]
lifecycle : Array[&Lifecycle]
commands : Array[&CommandPort]
ui : Array[&UiPort]
prompt_contributors : Array[&SystemPromptContributor]
requires : Array[Capability]
}

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

#
ExtensionManifest::empty

fn ExtensionManifest::empty(id~ : String) -> ExtensionManifest

Construct an empty manifest with only the id set. Fields can then be assigned individually.

#
HookStage

pub(all) enum HookStage {
ModelCompleted(completion~ :
Completion
)
ToolCompleted(call~ :
ToolCall
, outcome~ :
ToolOutcome
)
ToolFailed(call~ :
ToolCall
, reason~ : String)
ModelFailed(failure~ :
ModelFailure
)
} derive(
Debug
)

Describes what just completed, delivered after the effect is committed.
impl Show for HookStage

#
ParamType

pub(all) enum ParamType {
Str
Int
Bool
Strs
} derive(Eq,
Debug
)

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

#
ProviderConfig

pub(all) struct ProviderConfig {
reasoning_effort_values : Array[String]
context_window : Int?
compact_threshold : Double?
} derive(Eq,
Debug
)

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.

#
ProviderConfig::ProviderConfig

fn ProviderConfig::ProviderConfig(reasoning_effort_values~ : Array[String], context_window? : Int?, compact_threshold? : Double?) -> ProviderConfig

#
ProviderConfig::accepts_reasoning_effort

fn ProviderConfig::accepts_reasoning_effort(self : ProviderConfig, value : String) -> Bool

Check if the given reasoning_effort value is accepted.

#
ProviderConfig::empty

Empty config: no reasoning_effort values accepted, no window reported.

#
ToolDecision

pub(all) enum ToolDecision {
Approve(call~ :
ToolCall
)
ApproveAfterConsent(call~ :
ToolCall
, consent_scope~ : String)
Defer(reason~ : String)
Reject(reason~ : String)
} derive(
Debug
)

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.

#
UiBody

pub(all) enum UiBody {
Text(String)
Lines(Array[String])
KeyValue(Array[(String, String)])
Progress(Double)
Markdown(String)
} derive(
Debug
)

Structured payload a render intent carries. Host decides layout and color.
impl Show for UiBody

#
UiBody::to_string

fn UiBody::to_string(self : UiBody) -> String

#
UiDescriptor

pub(all) struct UiDescriptor {
autocomplete_sources : Array[AutocompleteSource]
} derive(
Debug
)

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

#
UiDescriptor::empty

fn UiDescriptor::empty() -> UiDescriptor

Empty descriptor used by UIs that contribute no autocomplete sources.

#
UiHint

pub(all) enum UiHint {
RefreshModelList
RefreshSettings
ClosePanel
Toast
None
} derive(Eq,
Debug
)

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.

#
UiRender

pub(all) struct UiRender {
slot : UiSlot
key : String
title : String?
body : UiBody
ttl_ms : Int?
} derive(
Debug
)

One render intent. Same key replaces a prior intent in the same UiSlot.
impl Show for UiRender

#
UiRequest

pub(all) enum UiRequest {
Input(prompt~ : String, default~ : String?)
Confirm(prompt~ : String)
Select(prompt~ : String, options~ : Array[String], default~ : Int?)
} derive(
Debug
)

User interaction primitives. Hosts that lack a variant raise UiError::Unsupported.
impl Show for UiRequest

#
UiResponse

pub(all) enum UiResponse {
Text(String)
Yes
No
Selected(Int)
Cancelled
} derive(Eq,
Debug
)

Response to a UiRequest. Cancelled = user dismissed; no UI = UiError::Unsupported.
impl Show for UiResponse

#
UiSlot

pub(all) enum UiSlot {
Status
Notice
Widget
} derive(Eq,
Debug
)

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

#
UiSlot::to_string

fn UiSlot::to_string(self : UiSlot) -> String