From 94b517f97441e33a4fca345816baa28a126d853d Mon Sep 17 00:00:00 2001 From: colmugx Date: Thu, 1 Oct 2026 21:00:11 +0800 Subject: [PATCH] refactor: reorganize source packages by domain boundaries --- src/agent/agent.mbt | 1283 +++++++++++++++++++++++++ src/agent/agent_catalog.mbt | 176 ++++ src/agent/agent_control.mbt | 158 ++++ src/agent/agent_projection.mbt | 350 +++++++ src/agent/agent_puppet.mbt | 42 + src/agent/agent_task.mbt | 727 ++++++++++++++ src/agent/agent_turn.mbt | 1352 +++++++++++++++++++++++++++ src/agent/moon.pkg | 1 + src/builtin/builtin.mbt | 337 +++++++ src/builtin/builtin_command.mbt | 329 +++++++ src/builtin/builtin_hooks.mbt | 302 ++++++ src/builtin/builtin_memory.mbt | 365 ++++++++ src/builtin/moon.pkg | 1 + src/extension/extends.mbt | 29 + src/extension/moon.pkg | 1 + src/manifest/manifest_aggregate.mbt | 337 +++++++ src/manifest/moon.pkg | 1 + src/runtime/basic_host_runtime.mbt | 76 ++ src/runtime/chunk_dispatch.mbt | 135 +++ src/runtime/moon.pkg | 7 +- src/runtime/runtime_shim.mbt | 70 ++ 21 files changed, 6073 insertions(+), 6 deletions(-) create mode 100644 src/agent/agent.mbt create mode 100644 src/agent/agent_catalog.mbt create mode 100644 src/agent/agent_control.mbt create mode 100644 src/agent/agent_projection.mbt create mode 100644 src/agent/agent_puppet.mbt create mode 100644 src/agent/agent_task.mbt create mode 100644 src/agent/agent_turn.mbt create mode 100644 src/agent/moon.pkg create mode 100644 src/builtin/builtin.mbt create mode 100644 src/builtin/builtin_command.mbt create mode 100644 src/builtin/builtin_hooks.mbt create mode 100644 src/builtin/builtin_memory.mbt create mode 100644 src/builtin/moon.pkg create mode 100644 src/extension/extends.mbt create mode 100644 src/extension/moon.pkg create mode 100644 src/manifest/manifest_aggregate.mbt create mode 100644 src/manifest/moon.pkg create mode 100644 src/runtime/basic_host_runtime.mbt create mode 100644 src/runtime/chunk_dispatch.mbt create mode 100644 src/runtime/runtime_shim.mbt diff --git a/src/agent/agent.mbt b/src/agent/agent.mbt new file mode 100644 index 0000000..df15236 --- /dev/null +++ b/src/agent/agent.mbt @@ -0,0 +1,1283 @@ +///| +/// 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). +pub struct AgentConfig { + max_tool_rounds : Int? + temperature : Double? + max_output_tokens : Int? + model_context_window : Int? + /// Host override for the auto-compact trigger point as a fraction of the + /// context window, in (0, 1]. `None` defers to the active modelport's + /// reported `compact_threshold`, falling back to the core default (0.88). + compact_threshold : Double? + /// Optional base system prompt. When non-empty, or when any extension + /// registers `SystemPromptContributor`s, Posoco prepends one stable + /// SystemMessage assembled from this base plus contributor sections. + system_prompt : String? + /// Optional per-provider cap for the session-opening `MemoryPort::inbound` + /// read, in milliseconds. `None` means no timeout. + memory_inbound_timeout_ms : Int? +} + +///| +/// Construct an `AgentConfig` naming only the knobs you set; every omitted +/// knob is `None`. Prefer this over record literals so new optional fields +/// never break construction sites. +pub fn agent_config( + max_tool_rounds? : Int? = None, + temperature? : Double? = None, + max_output_tokens? : Int? = None, + model_context_window? : Int? = None, + compact_threshold? : Double? = None, + system_prompt? : String? = None, + memory_inbound_timeout_ms? : Int? = None, +) -> AgentConfig { + { + max_tool_rounds, + temperature, + max_output_tokens, + model_context_window, + compact_threshold, + system_prompt, + memory_inbound_timeout_ms, + } +} + +///| +/// Encode the provider-agnostic chat options for `RunPolicy.call_options`. +/// Provider-specific configuration belongs to the ModelPort extension and +/// never crosses the canonical HostRuntime seam. +fn AgentConfig::to_call_options_json(self : AgentConfig) -> Json { + let fields : Map[String, Json] = Map::from_array([]) + match self.temperature { + Some(t) => fields["temperature"] = Json::number(t) + None => () + } + match self.max_output_tokens { + Some(n) => fields["max_output_tokens"] = Json::number(n.to_double()) + None => () + } + Json::object(fields) +} + +///| +/// Internal routing table: tool name → owning provider. Built during Agent::new. +priv struct ToolRouting { + tool_map : Map[String, &@port.ToolProvider] + providers : Array[&@port.ToolProvider] +} + +///| +/// Bounded label with an explicit cap; compact failure renderings are already +/// sanitized per field and only need an outer bound. +fn bounded_error_label(value : String, cap : Int) -> String { + let chars : Array[Char] = [] + let mut truncated = false + for char in value { + if chars.length() >= cap { + truncated = true + break + } + match char { + '\n' | '\r' => chars.push(' ') + other => chars.push(other) + } + } + let label = String::from_array(chars) + if truncated { + label + "...(truncated)" + } else { + label + } +} + +///| +fn safe_error_label(value : String) -> String { + bounded_error_label(value, 64) +} + +///| +fn snapshot_json(value : Json) -> Json { + match value { + Array(items) => Json::array(items.map(snapshot_json)) + Object(fields) => { + let snapshot : Map[String, Json] = Map::from_array([]) + for key in fields.keys() { + snapshot[key] = snapshot_json(fields[key]) + } + Json::object(snapshot) + } + scalar => scalar + } +} + +///| +fn snapshot_tool_call(call : @kernel.ToolCall) -> @kernel.ToolCall { + { + call_id: call.call_id, + name: call.name, + arguments: snapshot_json(call.arguments), + } +} + +///| +fn snapshot_content(content : @kernel.Content) -> @kernel.Content { + match content { + Text(text) => Text(text) + Image(media_type~, data~) => Image(media_type~, data~) + } +} + +///| +fn snapshot_message(message : @kernel.Message) -> @kernel.Message { + match message { + SystemMessage(content~) => + SystemMessage(content=content.map(snapshot_content)) + UserMessage(content~) => UserMessage(content=content.map(snapshot_content)) + AssistantMessage(content~, tool_calls~, reasoning~, finish_reason~) => + AssistantMessage( + content=content.map(snapshot_content), + tool_calls=tool_calls.map(snapshot_tool_call), + reasoning~, + finish_reason~, + ) + ToolMessage(call_id~, tool_name~, outcome~) => + ToolMessage(call_id~, tool_name~, outcome~) + } +} + +///| +fn snapshot_messages( + messages : Array[@kernel.Message], +) -> Array[@kernel.Message] { + messages.map(snapshot_message) +} + +///| +fn snapshot_metadata(metadata : Map[String, Json]) -> Map[String, Json] { + let snapshot : Map[String, Json] = Map::from_array([]) + for key in metadata.keys() { + snapshot[key] = snapshot_json(metadata[key]) + } + snapshot +} + +///| +fn snapshot_session( + messages : Array[@kernel.Message], + metadata : Map[String, Json], +) -> @types.Session { + { + messages: snapshot_messages(messages), + metadata: snapshot_metadata(metadata), + } +} + +///| +fn snapshot_tool_outcome(outcome : @kernel.ToolOutcome) -> @kernel.ToolOutcome { + match outcome { + Success(content~, structured~) => + Success( + content~, + structured=match structured { + Some(j) => Some(snapshot_json(j)) + None => None + }, + ) + SuccessWithAttachments(content~, structured~, attachments~) => + // Content blocks are immutable; attachments need no deep copy. + SuccessWithAttachments( + content~, + structured=match structured { + Some(j) => Some(snapshot_json(j)) + None => None + }, + attachments=attachments.copy(), + ) + ToolReportedError(content~, structured~) => + ToolReportedError( + content~, + structured=match structured { + Some(j) => Some(snapshot_json(j)) + None => None + }, + ) + RuntimeFailure(error_category~, message~) => + RuntimeFailure(error_category~, message~) + NotExecuted(reason~, original_call_id~) => + NotExecuted(reason~, original_call_id~) + } +} + +///| +fn tool_provenance(tool : @kernel.ToolDef) -> String { + match nonempty_provenance(tool) { + Some(source) => "ToolDef.provenance='\{safe_error_label(source)}'" + None => + "unlabeled provider declaring '\{safe_error_label(tool.name.to_string())}' (set ToolDef.provenance to a stable provider id)" + } +} + +///| +/// Build the tool routing table. Raises CompositionError::ToolCollision on +/// the FIRST name collision (fail-fast), rather than last-wins. The +/// collision message identifies the tool name and a provenance hint for each +/// conflicting provider (ToolDef.provenance if present, else an actionable marker). +fn build_tool_routing( + tools : Array[&@port.ToolProvider], +) -> ToolRouting raise @error.CompositionError { + let tool_map : Map[String, &@port.ToolProvider] = Map::from_array([]) + let seen_names = ToolNameIndex::new() + for provider in tools { + let tool_defs = provider.list_tools() + for tool in tool_defs { + let name = tool.name.to_string() + let provenance = tool_provenance(tool) + match seen_names.record(name, provenance) { + Some(labels) => + // T06-B: fail-fast on collision. The error names the tool and both + // sources so the caller can locate the conflict without guessing. + raise ToolCollision( + safe_error_label(name), + tool_collision_message(labels[0], provenance), + manifests=[], + ) + None => () + } + tool_map[name] = provider + } + } + { tool_map, providers: tools, } +} + +///| +/// Private mutable implementation owned by the Agent facade. +priv struct AgentRuntime { + puppet : @puppetry.Puppet + /// The aggregated ModelPort, queried per turn for the active model's + /// self-reported context window / compact threshold. Reference equality + /// with the port the Puppet executes through — no second composition path. + model : &@port.ModelPort + observers : Array[&@port.Observer] + /// The composed hook chain in interception order — the same array the + /// Puppet executes — retained for Agent-level dispatch points that fire + /// outside the pump (`PipelineHook::on_turn_end`). + hooks : Array[&@port.PipelineHook] + /// The aggregated memory sources paired with their manifest ids; drives + /// the session-opening inbound injection and the built-in memory tools. + memory : Array[(String, &@port.MemoryPort)] + /// Sessions whose inbound read already ran (freeze-even-if-empty: an + /// empty or failed read never retries within this process). + memory_inbound_attempted : Map[String, Bool] + sessions : Array[&@port.SessionStore] + lifecycle : Array[&@port.Lifecycle] + commands : Array[&@port.CommandPort] + builtin_command_port : BuiltinCommandPort + control : AgentControl + task_runtime : AgentTaskRuntime + config : AgentConfig + /// Host-owned versioned catalog (experimental runtime seam). `None` keeps + /// the static composition-time snapshot for the Agent's lifetime. + catalog_source : &@runtime.CatalogSource? + /// The source revision last read (0 = no source / not yet read). + mut last_catalog_revision : Int + /// Next monotonic catalog version to assign on a successful refresh. + /// Composition builds version 1; refreshes hand out 2, 3, ... + mut next_catalog_version : Int + /// Core basic tool definitions (the built-in memory surface), merged + /// ahead of every `CatalogSource` read. Empty when no basic tools exist. + basic_tool_defs : Array[@kernel.ToolDef] + mut next_run_seq : Int + mut shutdown_started : Bool + mut shutdown_complete : Bool + mut next_lifecycle_shutdown : Int + /// Whether lifecycle contributors have received their once-per-Agent + /// `on_start` notification. Fired inside the first `run_single_turn`, + /// after the shutdown guard and before the first `TurnStarted`. + mut start_notified : Bool + /// Synchronous product-boundary guard for the full `run_turn` call, + /// including any follow-ups drained by that call. The guard is acquired + /// before the first await so a rejected concurrent call cannot mutate + /// turn-scoped state before Puppet reports its lease as busy. + mut turn_active : Bool + /// Per-turn chunk dispatcher handle. The callback installed in the Puppet + /// reads this reference to route `StreamChunk` events to the active turn's + /// buffered queue; `run_turn_via_puppet` creates and swaps the dispatcher + /// for each turn. + chunk_dispatcher : Ref[ChunkDispatcher?] + /// Per-session cursor tracking how many leading messages are already + /// persisted. Shared with the committed-event checkpoint subscriber so + /// model/tool boundaries advance the same durability cursor the terminal + /// save path uses. + session_cursors : Map[String, Int] + /// The committed-event projection subscriber. Retained so the turn-failure + /// path can close still-pending tool calls with `ToolCallAbandoned`: the + /// Puppet's host-level rejection path (`reject_run`) intentionally emits + /// no terminal kernel event, so the Agent synthesizes the closure here. + event_subscriber : AgentEventSubscriber + /// Sessions whose canonical transcript was rewritten (compact/hook rewrite) + /// during a run. Incremental event projection pauses until the terminal + /// full-save re-establishes a canonical prefix. + checkpoint_dirty : Map[String, Bool] + body_recovery : Map[String, @types.Session] +} + +///| +/// Public deep module for agent developers. The runtime representation is a +/// private handle so canonical execution machinery never enters the interface. +pub struct Agent { + priv runtime : AgentRuntime +} + +///| +/// Reject out-of-range AgentConfig knobs before any port wiring happens. +fn validate_agent_config( + config : AgentConfig, +) -> Unit raise @error.CompositionError { + match config.max_tool_rounds { + Some(value) if value < 0 => + raise ManifestSchemaError( + manifest_id="agent.config", + detail="max_tool_rounds must be non-negative", + ) + _ => () + } + match config.model_context_window { + Some(value) if value <= 0 => + raise ManifestSchemaError( + manifest_id="agent.config", + detail="model_context_window must be positive", + ) + _ => () + } + match config.compact_threshold { + Some(value) if value <= 0.0 || value > 1.0 => + raise ManifestSchemaError( + manifest_id="agent.config", + detail="compact_threshold must be in (0, 1]", + ) + _ => () + } + match config.memory_inbound_timeout_ms { + Some(value) if value <= 0 => + raise ManifestSchemaError( + manifest_id="agent.config", + detail="memory_inbound_timeout_ms must be positive", + ) + _ => () + } +} + +///| +/// Build the tool routing table and the initial catalog snapshot. Core +/// basic tools (the built-in memory surface) are the base layer: they join +/// the routing like extension tools and lead the model-facing catalog — +/// both the port-derived snapshot and the merge ahead of a wired +/// `CatalogSource`. Built-in memory tools route through the same +/// ToolCollision fail-fast as extension tools: an extension also declaring +/// "memory_search" collides here at compose instead of shadowing or being +/// shadowed. A wired CatalogSource owns the extension portion of the +/// catalog from the start (version 1, built from its definitions merged +/// after the basic layer, failing fast when invalid); otherwise the catalog +/// is the static port-derived snapshot. +fn compose_tool_catalog( + agg : AggregatedPorts, + catalog_source : &@runtime.CatalogSource?, +) -> ( + ToolRouting, + @kernel_exec.ToolCatalogSnapshot, + Int, + Array[@kernel.ToolDef], + Map[String, &@port.ToolProvider], +) raise @error.CompositionError { + let providers = agg.tools.copy() + let basic_defs : Array[@kernel.ToolDef] = [] + let basic_tools : Map[String, &@port.ToolProvider] = Map::from_array([]) + if !agg.memory.is_empty() { + let observers = agg.observers + let mem_provider : &@port.ToolProvider = MemoryToolProvider( + sources=agg.memory, + on_failure=fn(reason) { + emit_secondary_failure(observers, "memory_search", reason) + }, + ) + providers.push(mem_provider) + for def in provider_tool_defs([mem_provider]) { + basic_tools[def.name.to_string()] = mem_provider + basic_defs.push(def) + } + } + let tool_routing = build_tool_routing(providers) + let (catalog, initial_catalog_revision) = match catalog_source { + Some(source) => + ( + build_catalog_from_defs( + merge_basic_catalog_defs(basic_defs, source.tools()), + CatalogVersion(1), + ), + source.revision(), + ) + None => (build_agent_catalog(tool_routing.providers), 0) + } + (tool_routing, catalog, initial_catalog_revision, basic_defs, basic_tools) +} + +///| +/// One composed hook chain, in interception order: system-prompt projection +/// first, extension-registered hooks next, UI rendering last (opt-in, so +/// extensions see events before the renderer). Memory has no hook: inbound +/// context is injected at the turn boundary (see `run_turn_via_puppet`) and +/// reads/writes go through the built-in MemoryToolProvider. +fn compose_hook_chain( + agg : AggregatedPorts, + config : AgentConfig, + ui_projection : Bool, +) -> Array[&@port.PipelineHook] { + let composed_hooks : Array[&@port.PipelineHook] = [] + // 1. System prompt projection (if a base prompt is configured or any + // contributors are registered). The base prompt lives in AgentConfig so + // the host can seed a stable system message without inventing a contributor. + let base_prompt = match config.system_prompt { + Some(p) => p + None => "" + } + if base_prompt != "" || !agg.prompt_contributors.is_empty() { + let sections : Array[SystemPromptSection] = [] + for pair in agg.prompt_contributors { + let (mid, contributor) = pair + sections.push(SystemPromptSection(id=mid, contributor~)) + } + let sys_hook = SystemPromptHook(base_prompt~, contributors=sections) + composed_hooks.push(sys_hook as &@port.PipelineHook) + } + for h in agg.hooks { + composed_hooks.push(h) + } + // UI projection is opt-in (BR-5): the built-in render policy is installed + // only when the host asks for it via `ui_projection=true`. + if ui_projection { + let ui_hook = UiRenderHook(ui=agg.ui) + composed_hooks.push(ui_hook as &@port.PipelineHook) + } + composed_hooks +} + +///| +/// Assemble the control mailbox, the built-in command port front end, the +/// aggregated command ports, and the effect-execution host (the caller's +/// override when provided, else a shim over the aggregated ports). A host +/// override is wrapped in `BasicFirstHostRuntime` when core basic tools +/// exist, so their execution does not depend on the host chain knowing them. +fn compose_mailbox_and_host( + agg : AggregatedPorts, + tool_routing : ToolRouting, + basic_tools : Map[String, &@port.ToolProvider], + host_override : &@kernel_exec.HostRuntime?, +) -> ( + &@puppetry.Mailbox, + BuiltinCommandPort, + Array[&@port.CommandPort], + &@kernel_exec.HostRuntime, +) { + let mailbox = @puppetry.ControlMailbox() + let mailbox_ref : &@puppetry.Mailbox = mailbox as &@puppetry.Mailbox + let builtin_command_port = BuiltinCommandPort(mailbox_ref) + let command_ports = compose_agent_commands(agg.commands, builtin_command_port) + let host : &@kernel_exec.HostRuntime = match host_override { + Some(h) => + if basic_tools.is_empty() { + h + } else { + BasicFirstHostRuntime::{ inner: h, basic: basic_tools, } + as &@kernel_exec.HostRuntime + } + None => { + let port_runtime = @runtime.PortRuntime( + model=agg.model, + tools=tool_routing.tool_map.copy(), + ) + RuntimeHostShim(port_runtime as &@runtime.Runtime) + as &@kernel_exec.HostRuntime + } + } + (mailbox_ref, builtin_command_port, command_ports, host) +} + +///| +/// Shared composition path for both public constructors. `host_override` +/// is `Some(shimmed runtime)` for `Agent::with_runtime`; `None` builds the +/// default `PortRuntime` over the aggregated ports and shims it through the +/// same path — there is exactly one way to assemble an Agent. +/// +/// `catalog_source` (experimental runtime seam): when `Some`, the tool +/// catalog is owned by the source — built from its definitions at +/// composition and rebuilt at prompt boundaries when its revision changes. +/// When `None`, the catalog is the static snapshot of the aggregated +/// `ToolProvider` declarations. +fn AgentRuntime::compose( + agg : AggregatedPorts, + config : AgentConfig, + ui_projection : Bool, + host_override : &@kernel_exec.HostRuntime?, + catalog_source : &@runtime.CatalogSource?, +) -> Agent raise @error.CompositionError { + validate_agent_config(config) + let (tool_routing, catalog, initial_catalog_revision, basic_defs, basic_tools) = compose_tool_catalog( + agg, catalog_source, + ) + let composed_hooks = compose_hook_chain(agg, config, ui_projection) + let (mailbox_ref, builtin_command_port, command_ports, host) = compose_mailbox_and_host( + agg, tool_routing, basic_tools, host_override, + ) + let journal = @puppetry.InMemoryJournal() + let chunk_dispatcher_ref : Ref[ChunkDispatcher?] = Ref(None) + let context_state_of : Ref[((String) -> @types.ContextState?)?] = Ref(None) + let task_runtime = AgentTaskRuntime::AgentTaskRuntime() + let control = AgentControl(mailbox_ref) + control.set_abort_hook(fn(run_id) { + task_runtime.cancel_foreground_for_run(run_id) + }) + let session_cursors : Map[String, Int] = Map::from_array([]) + let checkpoint_dirty : Map[String, Bool] = Map::from_array([]) + let checkpoint_subscriber = SessionCheckpointSubscriber( + agg.sessions, + session_cursors, + checkpoint_dirty, + ) + let event_subscriber = AgentEventSubscriber( + agg.observers, + chunk_dispatcher_ref, + context_state_of~, + ) + let session_store : &@port.SessionStore? = match agg.sessions { + [first, ..] => Some(first) + [] => None + } + let puppet_config : @puppetry.PuppetConfig = { + host, + catalog, + journal: journal as &@puppetry.RunJournal, + contributors: [], + subscribers: [ + checkpoint_subscriber as &@puppetry.EventSubscriber, + event_subscriber as &@puppetry.EventSubscriber, + ], + stream_chunks: create_agent_stream_callback( + agg.observers, + chunk_dispatcher_ref, + ), + hooks: composed_hooks, + max_iterations: match config.max_tool_rounds { + // The pump burns 2 iterations per tool round (AwaitingModel + + // AwaitingTools) plus one terminal iteration. Size the backstop so + // the kernel budget is always the limit that fires first; with an + // unbounded budget there is no pump cap either. + Some(rounds) => Some(2 * rounds + 1) + None => None + }, + commands: command_ports, + session_store, + mailbox: mailbox_ref, + } + // Deliver composed capabilities to lifecycle contributors, in + // registration order. Every composition gate has passed by this point + // (config checks, collision checks, atomic catalog), so + // the view carries the final composed ports. An extension raising here + // (e.g. CompositionError::ExtensionComposeFailed for an undeclared + // capability) fails the composition loudly; no partial Agent is produced. + for entry in agg.lifecycle { + let task_capability = task_runtime.capability(entry.id) + let view = @port.CompositionView::resolve( + requires=entry.requires, + model=agg.model, + decision=agg.decision, + log=agg.log, + ui=agg.ui, + tasks=Some(task_capability), + ) + entry.port.on_compose(view) + } + let runtime : AgentRuntime = { + puppet: Puppet(puppet_config), + model: agg.model, + observers: agg.observers, + hooks: composed_hooks, + memory: agg.memory, + memory_inbound_attempted: Map::from_array([]), + sessions: agg.sessions, + lifecycle: agg.lifecycle.map(fn(entry) { entry.port }), + commands: agg.commands, + builtin_command_port, + control, + task_runtime, + config, + catalog_source, + last_catalog_revision: initial_catalog_revision, + next_catalog_version: 2, + basic_tool_defs: basic_defs, + next_run_seq: 1, + shutdown_started: false, + shutdown_complete: false, + next_lifecycle_shutdown: agg.lifecycle.length() - 1, + start_notified: false, + turn_active: false, + chunk_dispatcher: chunk_dispatcher_ref, + session_cursors, + event_subscriber, + checkpoint_dirty, + body_recovery: Map::from_array([]), + } + builtin_command_port.runtime.val = Some(runtime) + context_state_of.val = Some(fn(session_id) { + runtime.puppet.session_context_state(session_id) + }) + { runtime, } +} + +///| +/// 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. +pub fn Agent::Agent( + exts~ : Array[&@port.Extension], + config~ : AgentConfig, + ui_projection? : Bool = false, +) -> Agent raise @error.CompositionError { + let agg = aggregate_extensions(exts) + AgentRuntime::compose(agg, config, ui_projection, None, None) +} + +///| +/// 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 +/// extension portion of the tool catalog — Posoco merges the core basic +/// tools (the built-in memory surface) ahead of the source's definitions, +/// reads the source 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. +pub fn Agent::with_runtime( + exts~ : Array[&@port.Extension], + config~ : AgentConfig, + runtime~ : &@runtime.Runtime, + ui_projection? : Bool = false, + catalog_source? : &@runtime.CatalogSource, +) -> Agent raise @error.CompositionError { + let agg = aggregate_extensions(exts) + AgentRuntime::compose( + agg, + config, + ui_projection, + Some(RuntimeHostShim(runtime) as &@kernel_exec.HostRuntime), + catalog_source, + ) +} + +///| +/// Own an Agent and all of its managed work for one structured lifetime. +/// The body receives the same Agent value that extensions use for turns; +/// returning, raising, or being cancelled closes the task scope and then +/// shuts down the Agent. +pub async fn[X] Agent::run_scoped(self : Agent, body : async (Agent) -> X) -> X { + if !self.runtime.task_runtime.scope_available() { + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "task scope is already active or the Agent is closed", + ), + ) + } + @async.with_task_group(async fn(group) { + let owns_scope = Ref(false) + // A raw cancellation signal bypasses the catch around `body(self)` + // below, so the scope shutdown must also run on the unwind path + // (async 0.22). Idempotent: shutdown is a no-op once completed. + errdefer (if owns_scope.val { + ignore( + Ok(@async.protect_from_cancel(async fn() { self.runtime.shutdown() })) catch { + error => { + emit_secondary_failure( + self.runtime.observers, + "run_scoped_shutdown", + error.to_string(), + ) + Err(error) + } + }, + ) + }) + let startup : Result[Unit, Error] = Ok( + { + self.runtime.task_runtime.activate(group) + owns_scope.val = true + self.runtime.notify_start_if_first() + }, + ) catch { + error => Err(error) + } + let body_result : Result[X, Error] = match startup { + Err(error) => Err(error) + Ok(_) => Ok(body(self)) catch { error => Err(error) } + } + let shutdown_result : Result[Unit, Error] = if owns_scope.val { + Ok(@async.protect_from_cancel(async fn() { self.runtime.shutdown() })) catch { + error => Err(error) + } + } else { + Ok(()) + } + match body_result { + Ok(value) => + match shutdown_result { + Ok(_) => value + Err(error) => raise error + } + Err(error) => + match shutdown_result { + Ok(_) => raise error + Err(cleanup_error) => { + emit_secondary_failure( + self.runtime.observers, + "run_scoped_shutdown", + cleanup_error.to_string(), + ) + raise error + } + } + } + }) +} + +///| +async fn AgentRuntime::shutdown(self : AgentRuntime) -> Unit { + if self.shutdown_complete { + return + } + self.shutdown_started = true + let task_shutdown : Result[Unit, @error.AgentError] = Ok( + self.task_runtime.shutdown(), + ) catch { + error => Err(error) + } + match task_shutdown { + Ok(_) => () + Err(error) => { + emit_managed_task_shutdown_failure( + self.observers, + error.to_string(), + self.task_runtime.shutdown_straggler_detail(), + ) + raise error + } + } + self.puppet.shutdown() catch { + error => + raise @error.AgentError::Runtime( + InvocationFailed( + "puppet shutdown: " + safe_error_label(error.to_string()), + ), + ) + } + while self.next_lifecycle_shutdown >= 0 { + // Advance the cursor only after successful cleanup. If an extension + // shutdown fails, the error remains loud and a retry resumes at the + // failing extension instead of skipping it or repeating completed ones. + self.lifecycle[self.next_lifecycle_shutdown].on_shutdown() + self.next_lifecycle_shutdown = self.next_lifecycle_shutdown - 1 + } + self.shutdown_complete = true +} + +///| +fn agent_error_category(error : Error) -> String { + match error { + @error.AgentError::Model(_) => "AgentError::Model" + @error.AgentError::Session(_) => "AgentError::Session" + @error.AgentError::Runtime(_) => "AgentError::Runtime" + @error.AgentError::ToolLoopExceeded(..) => "AgentError::ToolLoopExceeded" + @error.AgentError::PipelineAborted(_) => "AgentError::PipelineAborted" + @error.AgentError::Cancelled(_) => "AgentError::Cancelled" + @error.AgentError::AutoCompactFailed(_) => "AgentError::AutoCompactFailed" + _ => "Unclassified" + } +} + +///| +fn contextualize_session_error( + error : @error.SessionError, + session_id : String, + operation : String, +) -> @error.AgentError { + let cause = safe_error_label(error.to_string()) + Session( + match error { + Load(_) => + Load( + "session_id='\{safe_error_label(session_id)}', operation='\{operation}', category='SessionError::Load', cause='\{cause}'", + ) + Save(_) => + Save( + "session_id='\{safe_error_label(session_id)}', operation='\{operation}', category='SessionError::Save', cause='\{cause}'", + ) + Conflict(_) => + Conflict( + "session_id='\{safe_error_label(session_id)}', operation='\{operation}', category='SessionError::Conflict', cause='\{cause}'", + ) + }, + ) +} + +///| +/// Contextualize a generic `Error` raised by a parallel session-save wave as a +/// `SessionError::Save`. The thunks themselves return `Result[Unit, +/// SessionError]`, so this path only handles async-runtime/cancellation errors +/// that escape `@async.all` itself. +fn save_error_context( + error : Error, + session_id : String, + operation : String, +) -> @error.AgentError { + let context = "session_id='\{safe_error_label(session_id)}', operation='\{operation}', category='SessionError::Save', cause='\{safe_error_label(error.to_string())}'" + Session(Save(context)) +} + +///| +fn snapshot_opt_json(value : Json?) -> Json? { + match value { + Some(j) => Some(snapshot_json(j)) + None => None + } +} + +///| +fn snapshot_tool_failure(failure : @kernel.ToolFailure) -> @kernel.ToolFailure { + match failure { + ReportedError(content~, structured~) => + ReportedError(content~, structured=snapshot_opt_json(structured)) + Runtime(category~, message~) => Runtime(category~, message~) + } +} + +///| +fn snapshot_turn_event(event : @types.TurnEvent) -> @types.TurnEvent { + match event { + TurnStarted => TurnStarted + ToolCallPending(call) => ToolCallPending(snapshot_tool_call(call)) + ToolCallApproved(call~, consent_scope~) => + ToolCallApproved(call=snapshot_tool_call(call), consent_scope~) + ToolCallStarted(call~) => ToolCallStarted(call=snapshot_tool_call(call)) + ToolCallSucceeded(call~, content~, structured~, attachments~) => + ToolCallSucceeded( + call=snapshot_tool_call(call), + content~, + structured=snapshot_opt_json(structured), + attachments=attachments.copy(), + ) + ToolCallFailed(call~, failure~) => + ToolCallFailed( + call=snapshot_tool_call(call), + failure=snapshot_tool_failure(failure), + ) + ToolCallRejected(call~, reason~) => + ToolCallRejected(call=snapshot_tool_call(call), reason~) + ToolCallAbandoned(call~, reason~) => + ToolCallAbandoned(call=snapshot_tool_call(call), reason~) + ModelResponseReceived(message~, usage~) => + ModelResponseReceived(message=snapshot_message(message), usage~) + SessionRedirect(from~, to~, messages_before~, messages_after~) => + SessionRedirect(from~, to~, messages_before~, messages_after~) + TurnCompleted => TurnCompleted + TurnFailed(reason) => TurnFailed(reason) + StreamChunkReceived(chunk~) => StreamChunkReceived(chunk~) + StreamChunksDropped(count~) => StreamChunksDropped(count~) + ConfigWarning(field~, value~, reason~) => + ConfigWarning(field~, value~, reason~) + ConfigChanged(field~, old_value~, new_value~) => + ConfigChanged(field~, old_value~, new_value~) + ContextStateUpdated(state~) => ContextStateUpdated(state~) + CompactStarted(trigger~) => CompactStarted(trigger~) + CompactFinished(trigger~, mode~, final_session_id~, messages_after~) => + CompactFinished(trigger~, mode~, final_session_id~, messages_after~) + OperationFinalized(operation~, outcome~, detail~) => + OperationFinalized(operation~, outcome~, detail~) + Custom(source~, label~, data~) => + Custom(source~, label~, data=snapshot_json(data)) + UserRequestStarted(kind~, prompt~) => + UserRequestStarted(kind~, prompt=safe_error_label(prompt)) + UserRequestFinished(kind~, outcome~) => UserRequestFinished(kind~, outcome~) + } +} + +///| +fn AgentRuntime::emit_turn_event( + self : AgentRuntime, + scope : @types.EventScope?, + event : @types.TurnEvent, +) -> Unit { + for observer in self.observers { + observer.on_event_at(scope, snapshot_turn_event(event)) + } +} + +///| +/// Dispatch `PipelineHook::on_turn_begin` in registration order, after the +/// `TurnStarted` observer projection and before the pump's first +/// `before_model`. Symmetric with `dispatch_on_turn_end`: pre-turn failures +/// (the shutdown guard, a lifecycle `on_start` raise) dispatch nothing — the +/// turn never began. A raise is a secondary failure: observers get a +/// `secondary_failure` event, the turn still proceeds, and later hooks still +/// run. Cancellation travels as a runtime signal that bypasses this handler, +/// so only genuine defects are reported. +async fn AgentRuntime::dispatch_on_turn_begin( + self : AgentRuntime, +) -> Unit noraise { + for hook in self.hooks { + hook.on_turn_begin() catch { + error => + emit_secondary_failure( + self.observers, + "on_turn_begin", + error.to_string(), + ) + } + } +} + +///| +/// Dispatch `PipelineHook::on_turn_end` in registration order, after the terminal +/// observer projection and just before `run_single_turn` returns. Dispatched +/// on both the completed and the failed path. The terminal outcome is not a +/// payload — hooks read it from the Observer channel (`TurnCompleted` / +/// `TurnFailed`), which fires first. A raise +/// is a secondary failure: observers get a `secondary_failure` event, the +/// turn's primary outcome is never replaced, and later hooks still run. +/// Cancellation travels as a runtime signal that bypasses this handler, +/// so only genuine defects are reported. +async fn AgentRuntime::dispatch_on_turn_end( + self : AgentRuntime, +) -> Unit noraise { + for hook in self.hooks { + hook.on_turn_end() catch { + error => + emit_secondary_failure(self.observers, "on_turn_end", error.to_string()) + } + } +} + +///| +/// Emit a sanitized secondary-failure diagnostic to every observer's +/// session-level channel. Secondary failures never replace the primary +/// outcome; the payload carries only a bounded category label, never the +/// failing component's raw payload. (`Observer::on_event` is non-raising +/// by contract — a violating observer aborts loudly, which the ADR's +/// error-transparency rules intentionally preserve.) +fn emit_secondary_failure( + observers : Array[&@port.Observer], + hook_point : String, + detail : String, +) -> Unit { + let event : @types.TurnEvent = Custom( + source="posoco.core", + label="secondary_failure", + data=Json::object( + Map::from_array([ + ("hook_point", Json::string(hook_point)), + ("error", Json::string(safe_error_label(detail))), + ]), + ), + ) + for observer in observers { + // Secondary failures are emitted from hook closures and refresh paths + // that hold no run identity — scope is None by design (v1). + observer.on_event_at(None, snapshot_turn_event(event)) + } +} + +///| +fn emit_managed_task_shutdown_failure( + observers : Array[&@port.Observer], + error : String, + stragglers : String, +) -> Unit { + let event : @types.TurnEvent = Custom( + source="posoco.core", + label="secondary_failure", + data=Json::object( + Map::from_array([ + ("hook_point", Json::string("managed_task_shutdown")), + ("error", Json::string(safe_error_label(error))), + // This value is core-generated task identity/status metadata, not + // arbitrary worker error text. Keep it intact so late fields such + // as supervisor status are not lost to the short error-label cap. + ("stragglers", Json::string(stragglers)), + ]), + ), + ) + for observer in observers { + observer.on_event_at(None, snapshot_turn_event(event)) + } +} + +///| +fn AgentRuntime::ensure_agent_running( + self : AgentRuntime, +) -> Unit raise @error.AgentError { + if self.shutdown_started { + raise Runtime(InvocationFailed("agent is shut down")) + } +} + +///| +/// Cap on follow-up turns drained within a single `run_turn` call, aligned +/// with the control queue bound. Prevents an unbounded follow-up chain from +/// monopolising one call; leftovers stay queued for the next call. +let follow_up_drain_limit : Int = 64 + +///| +/// Run one agent turn, then drain any follow-ups submitted while it (or a +/// drained follow-up turn) was in flight. Each drained follow-up drives its +/// own full turn on the same session — same boundaries, same observer +/// events — and the last turn's `TurnResult` is returned. +async fn AgentRuntime::run_turn( + self : AgentRuntime, + input : @kernel.Message, + session_id : String, +) -> @types.TurnResult raise @error.AgentError { + self.run_turn_with_mode(input, session_id, false) +} + +///| +/// Run a turn under the Agent's single-turn guard. Recovery may replay a +/// persisted trailing user message; ordinary turns and drained follow-ups +/// always append their input. +async fn AgentRuntime::run_turn_with_mode( + self : AgentRuntime, + input : @kernel.Message, + session_id : String, + resume_persisted_user : Bool, +) -> @types.TurnResult raise @error.AgentError { + if self.turn_active { + raise Runtime(InvocationFailed("agent turn busy")) + } + // Acquire before the first async call. The guard covers the initial turn + // and every follow-up drained below, so no loser can touch lifecycle, + // identity, session, memory, catalog, or dispatcher state. + self.turn_active = true + // A host hard-cancel delivers a raw cancellation signal that bypasses the + // catch below (a re-raising with_task_group can surface it raw), so the + // guard must also release on unwind or the Agent stays wedged busy. + errdefer { + self.turn_active = false + } + let result : Result[@types.TurnResult, @error.AgentError] = Ok( + { + let mut result = self.run_single_turn( + input, session_id, resume_persisted_user, + ) + let mut drained = 0 + while drained < follow_up_drain_limit { + match self.control.take_follow_up() { + Some(message) => { + drained = drained + 1 + result = self.run_single_turn(message, session_id, false) + } + None => break + } + } + result + }, + ) catch { + error => Err(error) + } + self.turn_active = false + match result { + Ok(value) => value + Err(error) => raise error + } +} + +///| +async fn AgentRuntime::notify_start_if_first( + self : AgentRuntime, +) -> Unit raise @error.AgentError { + if self.start_notified { + return + } + for lc in self.lifecycle { + lc.on_start() catch { + error => + raise Runtime( + InvocationFailed( + "lifecycle on_start: " + safe_error_label(error.to_string()), + ), + ) + } + } + self.start_notified = true +} + +///| +/// Run exactly one turn: delegate to Puppet; emit start/end events for +/// observers. +#warnings("-fragile_catch_all") +async fn AgentRuntime::run_single_turn( + self : AgentRuntime, + input : @kernel.Message, + session_id : String, + resume_persisted_user : Bool, +) -> @types.TurnResult raise @error.AgentError { + self.ensure_agent_running() + self.notify_start_if_first() + // Mint run/turn identity up front so even TurnStarted carries attribution + // scope; the same identity drives the Puppet request below. + let identity = self.mint_turn_identity(session_id) + self.task_runtime.begin_operation( + session_id, + identity.run_id, + identity.turn_id, + ) + self.emit_turn_event(Some(identity.scope()), TurnStarted) + // Turn-begin hooks fire after the TurnStarted projection and before the + // pump's first before_model. Pre-turn failures above dispatch nothing — + // symmetric with on_turn_end's terminal-position semantics. + self.dispatch_on_turn_begin() + + let result = self.run_turn_via_puppet( + input, session_id, identity, resume_persisted_user, + ) catch { + primary => { + let cleanup : Result[Unit, @error.AgentError] = Ok( + self.task_runtime.finish_active_operation(), + ) catch { + error => Err(error) + } + match cleanup { + Ok(_) => () + Err(error) => + emit_secondary_failure( + self.observers, + "foreground_task_cleanup", + error.to_string(), + ) + } + let safe_reason = "turn failed: " + agent_error_category(primary) + // The Puppet's host-level rejection path emits no terminal kernel + // event, so the Agent closes still-pending tool calls here — the + // abandonment must precede the turn-failure synthesis. + self.event_subscriber.drain_pending(Some(identity.scope()), "turn failed") + self.emit_turn_event(Some(identity.scope()), TurnFailed(safe_reason)) + self.dispatch_on_turn_end() + raise primary + } + } + + let cleanup : Result[Unit, @error.AgentError] = Ok( + self.task_runtime.finish_active_operation(), + ) catch { + error => Err(error) + } + match cleanup { + Ok(_) => { + self.emit_turn_event(Some(identity.scope()), TurnCompleted) + self.dispatch_on_turn_end() + result + } + Err(error) => { + self.event_subscriber.drain_pending(Some(identity.scope()), "turn failed") + self.emit_turn_event( + Some(identity.scope()), + TurnFailed("turn failed: AgentError::Runtime"), + ) + self.dispatch_on_turn_end() + raise error + } + } +} + +///| +pub async fn Agent::run_turn( + self : Agent, + input : @kernel.Message, + session_id : String, +) -> @types.TurnResult raise @error.AgentError { + self.runtime.run_turn(input, session_id) +} + +///| +/// Resume an interrupted turn from its persisted transcript. If the +/// transcript ends with a user message, that message is replayed as the +/// model input without appending a duplicate. Otherwise `continuation` is +/// appended as a new user message. +pub async fn Agent::resume_turn( + self : Agent, + session_id : String, + continuation? : String = "continue", +) -> @types.TurnResult raise @error.AgentError { + self.runtime.run_turn_with_mode( + @kernel.Message::UserMessage(content=[@kernel.Content::Text(continuation)]), + session_id, + true, + ) +} + +///| +pub async fn Agent::compact_session( + self : Agent, + session_id : String, +) -> @types.CompactOutcome raise @error.AgentError { + self.runtime.compact_session(session_id) +} + +///| +pub async fn Agent::context_state( + self : Agent, + session_id : String, +) -> @types.ContextState? { + match self.runtime.puppet.session_context_state(session_id) { + Some(state) => Some(state) + None => { + let loaded_result : Result[@types.Session, @error.AgentError] = Ok( + self.runtime.load_session_for_operation(session_id), + ) catch { + error => Err(error) + } + let loaded : @types.Session? = match loaded_result { + Ok(session) => Some(session) + Err(_) => None + } + match loaded { + Some(session) => @types.ContextState::from_metadata(session.metadata) + None => None + } + } + } +} + +///| +pub async fn Agent::shutdown(self : Agent) -> Unit { + self.runtime.shutdown() +} + +///| +/// 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. +pub fn Agent::control(self : Agent) -> AgentControl { + self.runtime.control +} diff --git a/src/agent/agent_catalog.mbt b/src/agent/agent_catalog.mbt new file mode 100644 index 0000000..ebb0a18 --- /dev/null +++ b/src/agent/agent_catalog.mbt @@ -0,0 +1,176 @@ +///| +/// Agent-side tool catalog: canonical schema normalization, the +/// composition snapshots, and the CatalogSource refresh seam. + +///| +/// Normalise a tool schema into the canonical form the Kernel catalog +/// accepts. The Kernel requires a JSON object (`{"type":"object",...}`) or a +/// boolean at the top level; providers often pass `Json::null()` to signal +/// "no schema". We convert `null` → empty object so providers keep working +/// without each one having to construct `{}`. +fn normalize_tool_schema(schema : Json) -> Json { + match schema { + Null => Json::object(Map::from_array([])) + other => other + } +} + +///| +/// Build a `ToolCatalogSnapshot` from canonical definitions. Used both for +/// the port-derived catalog (at composition) and for `CatalogSource` +/// refreshes (at prompt boundaries, with a bumped version). The schema is +/// normalised into the Kernel's required object/boolean form; `owner` and +/// `policy` are taken verbatim from each definition. +fn build_catalog_from_defs( + defs : Array[@kernel.ToolDef], + version : @kernel.CatalogVersion, +) -> @kernel_exec.ToolCatalogSnapshot raise @error.CompositionError { + let builder = @kernel_exec.ToolCatalogBuilder() + for tool in defs { + let def = @kernel.ToolDef( + name=tool.name, + description=tool.description, + input_schema=normalize_tool_schema(tool.input_schema), + owner=tool.owner, + policy=tool.policy, + provenance=tool.provenance, + ) + try { + let _ = builder.add(def) + } catch { + e => + raise ManifestSchemaError( + manifest_id="agent.catalog", + detail="catalog add '\{tool.name.to_string()}': " + + safe_error_label(e.to_string()), + ) + } + } + builder.finish(version~) catch { + e => + raise ManifestSchemaError( + manifest_id="agent.catalog", + detail="catalog finish: " + safe_error_label(e.to_string()), + ) + } +} + +///| +/// Canonical definitions for the aggregated tool providers. Each tool's +/// declared `policy` is honored as-is (M1-T06: declarations select behavior +/// through stable composition contracts); providers that do not care declare +/// `Parallel`, the legacy `@async.all`-every-batch default. Owner is derived +/// from the ToolDef's own `owner` field if it is non-placeholder, else from +/// provenance, else `legacy_provider`. +fn provider_tool_defs( + providers : Array[&@port.ToolProvider], +) -> Array[@kernel.ToolDef] { + let defs : Array[@kernel.ToolDef] = [] + for provider in providers { + for tool in provider.list_tools() { + let owner_str = tool.owner.to_string() + let owner : @kernel.OwnerId = if owner_str == "placeholder" || + owner_str == "" { + match tool.provenance { + Some(p) if p != "" => @kernel.OwnerId::unchecked(p) + _ => @kernel.OwnerId::unchecked("legacy_provider") + } + } else { + tool.owner + } + defs.push( + ToolDef( + name=tool.name, + description=tool.description, + input_schema=tool.input_schema, + owner~, + policy=tool.policy, + provenance=tool.provenance, + ), + ) + } + } + defs +} + +///| +/// Build a `ToolCatalogSnapshot` from the agent's aggregated tool providers. +fn build_agent_catalog( + providers : Array[&@port.ToolProvider], +) -> @kernel_exec.ToolCatalogSnapshot raise @error.CompositionError { + build_catalog_from_defs(provider_tool_defs(providers), CatalogVersion(1)) +} + +///| +/// Merge the core basic definitions ahead of a wired `CatalogSource`'s +/// definitions. Basic tools are the base layer, so a source definition +/// colliding with a basic name is shadowed (first-name-wins, basic first); +/// duplicates within the source list itself are not resolved here — the +/// catalog builder still rejects them. +fn merge_basic_catalog_defs( + basic : Array[@kernel.ToolDef], + source : Array[@kernel.ToolDef], +) -> Array[@kernel.ToolDef] { + let defs : Array[@kernel.ToolDef] = [] + let basic_names : Map[String, Bool] = Map::from_array([]) + for tool in basic { + defs.push(tool) + basic_names[tool.name.to_string()] = true + } + for tool in source { + if !basic_names.contains(tool.name.to_string()) { + defs.push(tool) + } + } + defs +} + +///| +/// Catalog refresh at a prompt boundary (experimental runtime seam). With +/// no `CatalogSource` wired this is a no-op and the catalog stays the +/// static composition snapshot. Otherwise a changed `revision()` triggers +/// exactly one rebuild attempt: +/// - success → the new snapshot (next monotonic catalog version) is swapped +/// into the Puppet; subsequent runs see it, in-flight runs never do; +/// - validation failure or a busy pump → the previous snapshot stays in +/// effect and the failure is surfaced as a `secondary_failure` observer +/// event. The turn is never aborted by a catalog problem. +/// The revision tracker advances on every attempt, so a deterministically +/// bad definition set is not re-validated (and re-reported) on every turn; +/// the source retries by changing `revision()` again. +fn AgentRuntime::refresh_catalog_if_changed(self : AgentRuntime) -> Unit { + match self.catalog_source { + None => () + Some(source) => { + let revision = source.revision() + if revision == self.last_catalog_revision { + return + } + self.last_catalog_revision = revision + let version = @kernel.CatalogVersion(self.next_catalog_version) + let snapshot = build_catalog_from_defs( + merge_basic_catalog_defs(self.basic_tool_defs, source.tools()), + version, + ) catch { + error => { + emit_secondary_failure( + self.observers, + "catalog_refresh", + "catalog revision \{revision} rejected: " + + safe_error_label(error.to_string()), + ) + return + } + } + if self.puppet.replace_catalog(snapshot) { + self.next_catalog_version = self.next_catalog_version + 1 + } else { + emit_secondary_failure( + self.observers, + "catalog_refresh", + "catalog revision \{revision} deferred: a run is active", + ) + } + } + } +} diff --git a/src/agent/agent_control.mbt b/src/agent/agent_control.mbt new file mode 100644 index 0000000..5182d9a --- /dev/null +++ b/src/agent/agent_control.mbt @@ -0,0 +1,158 @@ +///| +/// Opaque host-side control handle owned by one Agent. +/// +/// Hosts obtain this value from `Agent::control()`. The internal mailbox and +/// the follow-up drain remain private to the root package, so a host can +/// submit or observe control state without taking ownership of Agent's loop. + +///| +let default_max_follow_up_depth : Int = 64 + +///| +pub struct AgentControl { + priv mailbox : &@puppetry.Mailbox + priv follow_ups : @aqueue.Queue[@kernel.Message] + priv mut follow_up_depth : Int + priv mut seq : Int + priv max_depth : Int + priv mut abort_hook : ((@kernel.RunId) -> Unit)? +} + +///| +/// Framework-only construction. Product code receives this handle from an +/// Agent and cannot bind one to an arbitrary internal mailbox. +fn AgentControl::AgentControl(mailbox : &@puppetry.Mailbox) -> AgentControl { + { + mailbox, + follow_ups: Queue(kind=Unbounded), + follow_up_depth: 0, + seq: 0, + max_depth: default_max_follow_up_depth, + abort_hook: None, + } +} + +///| +/// Install the Agent-owned foreground cancellation callback. The callback is +/// invoked only after the mailbox accepts an abort for a live run. +fn AgentControl::set_abort_hook( + self : AgentControl, + hook : (@kernel.RunId) -> Unit, +) -> Unit { + self.abort_hook = Some(hook) +} + +///| +fn AgentControl::next_command_id( + self : AgentControl, + prefix : String, +) -> @puppetry.CommandId { + self.seq = self.seq + 1 + @puppetry.CommandId::unchecked("\{prefix}_\{self.seq}") +} + +///| +/// Currently-active run id, or `None` while the Agent is idle. +pub fn AgentControl::active_run_id(self : AgentControl) -> @kernel.RunId? { + self.mailbox.active_run_id() +} + +///| +/// Currently-active turn id, or `None` while the Agent is idle. +pub fn AgentControl::active_turn_id(self : AgentControl) -> @kernel.TurnId? { + self.mailbox.active_turn_id() +} + +///| +/// Submit a follow-up consumed by the Agent at a turn boundary. A stale or +/// absent run is rejected so a late task cannot target a later run. +pub fn AgentControl::enqueue_follow_up( + self : AgentControl, + message : @kernel.Message, +) -> @runtime.EnqueueOutcome { + let command_id = self.next_command_id("follow_up") + match (self.mailbox.active_run_id(), self.mailbox.active_turn_id()) { + (Some(_), Some(_)) => { + if self.follow_up_depth >= self.max_depth { + return @runtime.RejectedQueueFull(depth=self.follow_up_depth) + } + let put_result : Result[Bool, Error] = Ok( + self.follow_ups.try_put(message), + ) catch { + error => Err(error) + } + match put_result { + Err(error) => + return @runtime.RejectedQueueFailure( + reason="follow_up queue write failed: " + + safe_error_label(error.to_string()), + ) + Ok(false) => + return @runtime.RejectedQueueFull(depth=self.follow_up_depth) + Ok(true) => () + } + self.follow_up_depth = self.follow_up_depth + 1 + @runtime.Accepted(command_id=command_id.to_string()) + } + _ => @runtime.RejectedStale(reason="follow_up requires an active run") + } +} + +///| +/// Agent-internal drain at the boundary between full turns. +fn AgentControl::take_follow_up( + self : AgentControl, +) -> @kernel.Message? raise @error.AgentError { + let next : @kernel.Message? = self.follow_ups.try_get() catch { + error => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "follow_up queue read failed: " + safe_error_label(error.to_string()), + ), + ) + } + match next { + Some(message) => { + self.follow_up_depth = self.follow_up_depth - 1 + Some(message) + } + None => None + } +} + +///| +/// Number of follow-ups waiting for the Agent's next turn boundary. +pub fn AgentControl::pending_follow_ups(self : AgentControl) -> Int { + self.follow_up_depth +} + +///| +/// Request cancellation of the active run. Repeated requests return the +/// displayable `AbortAlreadyRequested` outcome. +pub fn AgentControl::abort_active( + self : AgentControl, + detail : String?, +) -> @runtime.EnqueueOutcome { + let command_id = self.next_command_id("abort") + let cmd : @puppetry.AbortCommand = { + command_id, + target_run_id: match self.mailbox.active_run_id() { + Some(run) => run + None => return @runtime.RejectedStale(reason="no active run to abort") + }, + detail, + } + match self.mailbox.enqueue_abort(cmd) { + @puppetry.Accepted(command_id~) => { + match self.abort_hook { + Some(hook) => hook(cmd.target_run_id) + None => () + } + @runtime.Accepted(command_id=command_id.to_string()) + } + @puppetry.RejectedStale(reason~, ..) => @runtime.RejectedStale(reason~) + @puppetry.RejectedQueueFull(depth~, ..) => + @runtime.RejectedQueueFull(depth~) + @puppetry.AbortAlreadyRequested(..) => @runtime.AbortAlreadyRequested + } +} diff --git a/src/agent/agent_projection.mbt b/src/agent/agent_projection.mbt new file mode 100644 index 0000000..db225ae --- /dev/null +++ b/src/agent/agent_projection.mbt @@ -0,0 +1,350 @@ +///| +/// Agent-facing projection of committed Puppet envelopes onto observers, +/// plus the per-turn stream-chunk callback wiring. + +///| +/// Correctness-critical persistence projection for committed model/tool facts. +/// Agent checkpoints the admitted input before Puppet starts; this subscriber +/// then awaits SessionStore checkpoints after each committed assistant/tool +/// boundary. A transcript rewrite marks the session dirty and pauses +/// incremental projection until the terminal full save re-establishes a +/// canonical prefix. +priv struct SessionCheckpointSubscriber { + stores : Array[&@port.SessionStore] + cursors : Map[String, Int] + dirty : Map[String, Bool] +} + +///| +fn SessionCheckpointSubscriber::SessionCheckpointSubscriber( + stores : Array[&@port.SessionStore], + cursors : Map[String, Int], + dirty : Map[String, Bool], +) -> SessionCheckpointSubscriber { + { stores, cursors, dirty, } +} + +///| +impl @puppetry.EventSubscriber for SessionCheckpointSubscriber with fn subscriber_id( + _self : SessionCheckpointSubscriber, +) -> String { + "agent_session_checkpoint" +} + +///| +impl @puppetry.EventSubscriber for SessionCheckpointSubscriber with fn provenance( + _self : SessionCheckpointSubscriber, +) -> String { + "posoco.agent.session" +} + +///| +async fn SessionCheckpointSubscriber::append_committed_message( + self : SessionCheckpointSubscriber, + session_id : String, + message : @kernel.Message, +) -> Unit raise @puppetry.SubscriberError { + if self.stores.is_empty() { + return + } + let cursor = match self.cursors.get(session_id) { + Some(value) => value + None => 0 + } + let results : Array[Result[Unit, @error.SessionError]] = @async.all( + self.stores.map(fn(store) { + () => { + let checkpoint : @port.SessionCheckpoint = { + from_index: cursor, + messages: [snapshot_message(message)], + metadata: None, + } + Ok(store.checkpoint(session_id, checkpoint)) catch { + error => Err(error) + } + } + }), + ) catch { + error => + raise @puppetry.SubscriberError::Internal( + subscriber_id="agent_session_checkpoint", + detail="checkpoint dispatch failed: " + error.to_string(), + ) + } + for result in results { + match result { + Ok(_) => () + Err(error) => + raise @puppetry.SubscriberError::Internal( + subscriber_id="agent_session_checkpoint", + detail="session_id=" + + safe_error_label(session_id) + + "; " + + error.to_string(), + ) + } + } + self.cursors[session_id] = cursor + 1 +} + +///| +impl @puppetry.EventSubscriber for SessionCheckpointSubscriber with fn on_event( + self : SessionCheckpointSubscriber, + envelope : @puppetry.EventEnvelope, +) -> Unit raise @puppetry.SubscriberError { + let session_id = envelope.session_id().to_string() + match envelope.event() { + RunStarted(..) => self.dirty.remove(session_id) + TranscriptRewritten(..) => self.dirty[session_id] = true + ModelStepCompleted(completion~, ..) => + if !self.dirty.contains(session_id) { + let payload = completion.message + self.append_committed_message( + session_id, + AssistantMessage( + content=payload.content, + tool_calls=payload.tool_calls, + reasoning=payload.reasoning, + finish_reason=payload.finish_reason, + ), + ) + } + ToolCompleted(call~, outcome~) => + if !self.dirty.contains(session_id) { + self.append_committed_message( + session_id, + ToolMessage(call_id=call.call_id, tool_name=call.name, outcome~), + ) + } + ToolCallsPreRejected(calls~, outcomes~, ..) => + // Pre-pass rejects are reducer-transcript facts (NotExecuted tool + // messages); mirror them exactly like folded completions. + if !self.dirty.contains(session_id) { + for i in 0.. () + } +} + +///| +/// Agent-facing projection of committed Puppet envelopes. It never scans a +/// final transcript. The pending call cache is correlation state populated by +/// `ToolBatchStarted` and consumed by completion events. A committed +/// Pending events retain the model's original call; completion events carry +/// the canonical call that reached the host. +priv struct AgentEventSubscriber { + observers : Array[&@port.Observer] + pending_calls : Map[String, @kernel.ToolCall] + chunk_dispatcher : Ref[ChunkDispatcher?] + /// Late-bound live context-state lookup (session id -> puppet state), + /// wired after the runtime exists in `AgentRuntime::compose`. `None` (or + /// a lookup returning `None`) projects nothing. + context_state_of : Ref[((String) -> @types.ContextState?)?] +} + +///| +fn AgentEventSubscriber::AgentEventSubscriber( + observers : Array[&@port.Observer], + chunk_dispatcher : Ref[ChunkDispatcher?], + context_state_of? : Ref[((String) -> @types.ContextState?)?] = Ref(None), +) -> AgentEventSubscriber { + { + observers, + pending_calls: Map::from_array([]), + chunk_dispatcher, + context_state_of, + } +} + +///| +impl @puppetry.EventSubscriber for AgentEventSubscriber with fn subscriber_id( + _self : AgentEventSubscriber, +) -> String { + "agent_committed_event_projection" +} + +///| +impl @puppetry.EventSubscriber for AgentEventSubscriber with fn provenance( + _self : AgentEventSubscriber, +) -> String { + "posoco.agent" +} + +///| +fn AgentEventSubscriber::emit( + self : AgentEventSubscriber, + scope : @types.EventScope?, + event : @types.TurnEvent, +) -> Unit { + // Piggyback flush: catch up buffered chunk telemetry before every committed + // event so observers see chunks before ModelResponseReceived, ToolCallPending, + // etc., and so they catch up at boundaries during an unbroken SSE burst. + match self.chunk_dispatcher.val { + Some(dispatcher) => dispatcher.flush() + None => () + } + for observer in self.observers { + observer.on_event_at(scope, snapshot_turn_event(event)) + } +} + +///| +fn AgentEventSubscriber::emit_tool_result( + self : AgentEventSubscriber, + scope : @types.EventScope, + call : @kernel.ToolCall, + outcome : @kernel.ToolOutcome, +) -> Unit raise @puppetry.SubscriberError { + let key = call.call_id.to_string() + if !self.pending_calls.contains(key) { + raise ContractViolation( + subscriber_id="agent_committed_event_projection", + detail="ToolCompleted without committed ToolBatchStarted for call " + key, + ) + } + self.pending_calls.remove(key) + // One outcome shape → one terminal tool event. The variant IS the node: + // success, failure and rejection never share an event. + let event : @types.TurnEvent = match outcome { + Success(content~, structured~) => + ToolCallSucceeded(call~, content~, structured~, attachments=[]) + SuccessWithAttachments(content~, structured~, attachments~) => + ToolCallSucceeded(call~, content~, structured~, attachments~) + ToolReportedError(content~, structured~) => + ToolCallFailed(call~, failure=ReportedError(content~, structured~)) + RuntimeFailure(error_category~, message~) => + ToolCallFailed(call~, failure=Runtime(category=error_category, message~)) + NotExecuted(reason~, ..) => ToolCallRejected(call~, reason~) + } + self.emit(Some(scope), event) +} + +///| +/// Abandon every still-pending call. Used when a run ends without folding +/// their completions (cancel, terminal hook failure, suspension) and +/// defensively when a new run starts over leftovers. With `scope` the drain +/// attributes to the run that owned the calls; `None` marks out-of-run +/// cleanup. +fn AgentEventSubscriber::drain_pending( + self : AgentEventSubscriber, + scope : @types.EventScope?, + reason : String, +) -> Unit { + if self.pending_calls.is_empty() { + return + } + for _key, call in self.pending_calls { + self.emit(scope, ToolCallAbandoned(call~, reason~)) + } + self.pending_calls.clear() +} + +///| +impl @puppetry.EventSubscriber for AgentEventSubscriber with fn on_event( + self : AgentEventSubscriber, + envelope : @puppetry.EventEnvelope, +) -> Unit raise @puppetry.SubscriberError { + let event = envelope.event() + // Envelope-projected events always carry the envelope's run identity as + // their attribution scope. + let scope : @types.EventScope = { + session_id: envelope.session_id(), + run_id: envelope.run_id(), + turn_id: envelope.turn_id(), + } + match event { + RunStarted(..) => + self.drain_pending(None, "run restarted before completion") + RunSuspended(..) => self.drain_pending(Some(scope), "run suspended") + RunCompleted(..) | RunFailed(..) | RunCancelled(..) => + // Terminal-only drain: cancelled tool effects still receive their + // closing ToolCompleted events between CancelRequested and the + // terminal event, so draining any earlier would break pairing. + self.drain_pending(Some(scope), "run terminal") + ToolCallsPreRejected(calls~, outcomes~, ..) => + // Pre-pass rejects never entered the pipeline: emit the rejection + // directly, no pending marker, nothing to drain. + for i in 0.. + self.emit(Some(scope), ToolCallRejected(call=calls[i], reason~)) + _ => + raise ContractViolation( + subscriber_id="agent_committed_event_projection", + detail="ToolCallsPreRejected carries a non-NotExecuted outcome", + ) + } + } + ToolCallApproved(model_step_id=_, call~, consent_scope~) => + self.emit(Some(scope), ToolCallApproved(call~, consent_scope~)) + ToolWaveStarted(calls~, ..) => + for call in calls { + self.emit(Some(scope), ToolCallStarted(call=snapshot_tool_call(call))) + } + ModelStepCompleted(completion~, ..) => { + let payload = completion.message + let message : @kernel.Message = AssistantMessage( + content=payload.content, + tool_calls=payload.tool_calls, + reasoning=payload.reasoning, + finish_reason=payload.finish_reason, + ) + self.emit( + Some(scope), + ModelResponseReceived(message~, usage=completion.usage), + ) + // The puppet recorded this step's usage into the session's context + // state before the boundary committed, so the lookup is fresh. + // Projecting per model step keeps the ctx indicator at the same + // cadence as cache — every round, not only at turn boundaries. + match self.context_state_of.val { + Some(lookup) => + match lookup(envelope.session_id().to_string()) { + Some(state) => self.emit(Some(scope), ContextStateUpdated(state~)) + None => () + } + None => () + } + } + ToolBatchStarted(calls~, ..) => + for call in calls { + let snapshot = snapshot_tool_call(call) + self.pending_calls[snapshot.call_id.to_string()] = snapshot + self.emit(Some(scope), ToolCallPending(snapshot)) + } + ToolCompleted(call~, outcome~) => + self.emit_tool_result(scope, call, outcome) + _ => () + } +} + +///| +/// Wrap the puppet's `HostChunkCallback` so each `StreamChunk` is re-emitted +/// to observers as `StreamChunkReceived`. The chunk is already the canonical +/// typed shape; no JSON decoding is needed here. +fn create_agent_stream_callback( + observers : Array[&@port.Observer], + dispatcher_ref : Ref[ChunkDispatcher?], +) -> @kernel_exec.HostChunkCallback? { + if observers.is_empty() { + return None + } + let cb = fn(chunk : @types.StreamChunk) { + // Route to the active turn's dispatcher. If no turn is active (should not + // happen for chunks produced by the Puppet), drop silently. + match dispatcher_ref.val { + Some(dispatcher) => dispatcher.enqueue(chunk) + None => () + } + } + Some(cb) +} diff --git a/src/agent/agent_puppet.mbt b/src/agent/agent_puppet.mbt new file mode 100644 index 0000000..5a30db2 --- /dev/null +++ b/src/agent/agent_puppet.mbt @@ -0,0 +1,42 @@ +///| +/// Agent-owned Puppet composition and per-prompt projection. +/// +/// Composition-static state is built exactly once in `Agent::Agent`: catalog, +/// HostRuntime adapter, journal, committed-event subscriber, mailbox and +/// Puppet. Per-turn execution lives in `agent_turn.mbt`; this file keeps the +/// per-prompt identity minted before `TurnStarted` is projected. + +///| +/// Per-turn identity minted by the Agent before `TurnStarted` is projected, +/// so every turn event — including the first — carries attribution scope. +/// The same identity drives the Puppet prompt request. +priv struct TurnIdentity { + seq : Int + run_id : @kernel.RunId + turn_id : @kernel.TurnId + session_id : @kernel.SessionId +} + +///| +/// Allocate the next unique run/turn identity. The counter is Agent-owned +/// and monotonic, so two calls on the same Agent never share run/turn ids +/// while still using the same Puppet and journal. +fn AgentRuntime::mint_turn_identity( + self : AgentRuntime, + session_id : String, +) -> TurnIdentity { + let seq = self.next_run_seq + self.next_run_seq = seq + 1 + { + seq, + run_id: @kernel.RunId::unchecked("agent_run_\{seq}"), + turn_id: @kernel.TurnId::unchecked("agent_turn_\{seq}"), + session_id: @kernel.SessionId::unchecked(session_id), + } +} + +///| +/// The attribution scope projected onto this turn's observer events. +fn TurnIdentity::scope(self : TurnIdentity) -> @types.EventScope { + { session_id: self.session_id, run_id: self.run_id, turn_id: self.turn_id, } +} diff --git a/src/agent/agent_task.mbt b/src/agent/agent_task.mbt new file mode 100644 index 0000000..93a965f --- /dev/null +++ b/src/agent/agent_task.mbt @@ -0,0 +1,727 @@ +///| +let task_outcomes_applied_metadata_key : String = "posoco.task.applied_ids" + +///| +let foreground_task_settle_timeout_ms : Int = 5_000 + +///| +let managed_task_shutdown_settle_timeout_ms : Int = 5_000 + +///| +/// Applied task ids may survive a session-store restart, so a process-local +/// counter alone cannot identify a new runtime. Use the platform entropy +/// source and fail activation if it is unavailable. +fn next_task_runtime_identity() -> String raise @error.AgentError { + let bytes = match @env.rand(16) { + Some(value) => value + None => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "task runtime nonce unavailable: platform entropy source returned no bytes", + ), + ) + } + let hex : Array[Char] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', + ] + let out = StringBuilder() + for i in 0..> 4) & 0x0f]) + out.write_char(hex[byte & 0x0f]) + } + out.to_string() +} + +///| +priv struct AgentTaskOperation { + session_id : String + run_id : @kernel.RunId + turn_id : @kernel.TurnId +} + +///| +priv struct AgentTaskRecord { + receipt : @port.TaskReceipt + task : @fuwaroid.SupervisedTask[@port.TaskOutcome] + operation : AgentTaskOperation? +} + +///| +priv struct AgentTaskRuntime { + mut supervisor : @fuwaroid.Supervisor[@port.TaskOutcome]? + mut scope_active : Bool + mut closed : Bool + mut runtime_id : String? + mut next_id : Int + mut active_operation : AgentTaskOperation? + foreground_settle_timeout_ms : Int + shutdown_settle_timeout_ms : Int + tasks : Map[String, AgentTaskRecord] + outcomes : Map[String, Array[@port.TaskOutcome]] + inflight : Map[String, Array[String]] +} + +///| +fn AgentTaskRuntime::AgentTaskRuntime( + foreground_settle_timeout_ms? : Int = foreground_task_settle_timeout_ms, + shutdown_settle_timeout_ms? : Int = managed_task_shutdown_settle_timeout_ms, +) -> AgentTaskRuntime { + { + supervisor: None, + scope_active: false, + closed: false, + runtime_id: None, + next_id: 1, + active_operation: None, + foreground_settle_timeout_ms, + shutdown_settle_timeout_ms, + tasks: Map::from_array([]), + outcomes: Map::from_array([]), + inflight: Map::from_array([]), + } +} + +///| +fn AgentTaskRuntime::capability( + self : AgentTaskRuntime, + extension_id : String, +) -> @port.Tasks { + @port.Tasks::from_submit(submit=fn(spec) { self.submit(extension_id, spec) }) +} + +///| +fn[G] AgentTaskRuntime::activate( + self : AgentTaskRuntime, + group : @async.TaskGroup[G], +) -> Unit raise @error.AgentError { + if self.closed { + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed("task runtime is closed"), + ) + } + if self.scope_active { + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed("task scope is already active"), + ) + } + self.runtime_id = Some(next_task_runtime_identity()) + self.supervisor = Some(@fuwaroid.Supervisor(group~)) + self.scope_active = true +} + +///| +fn AgentTaskRuntime::scope_available(self : AgentTaskRuntime) -> Bool { + !self.closed && !self.scope_active +} + +///| +fn agent_task_operation_equal( + left : AgentTaskOperation, + right : AgentTaskOperation, +) -> Bool { + left.session_id == right.session_id && + left.run_id == right.run_id && + left.turn_id == right.turn_id +} + +///| +fn AgentTaskRuntime::record_matches_operation( + _self : AgentTaskRuntime, + record : AgentTaskRecord, + operation : AgentTaskOperation, +) -> Bool { + match record.receipt.mode { + @port.TaskMode::Foreground => + match record.operation { + Some(owner) => agent_task_operation_equal(owner, operation) + None => false + } + @port.TaskMode::Background => false + } +} + +///| +fn supervised_task_terminal(status : @fuwaroid.SupervisedTaskStatus) -> Bool { + match status { + @fuwaroid.SupervisedTaskStatus::Completed + | @fuwaroid.SupervisedTaskStatus::Cancelled + | @fuwaroid.SupervisedTaskStatus::Failed => true + @fuwaroid.SupervisedTaskStatus::Running + | @fuwaroid.SupervisedTaskStatus::Cancelling => false + } +} + +///| +fn AgentTaskRuntime::refresh_active_operation(self : AgentTaskRuntime) -> Unit { + match self.active_operation { + None => () + Some(operation) => { + let stale : Array[String] = [] + let mut unresolved = false + for record in self.tasks.values() { + if self.record_matches_operation(record, operation) { + if supervised_task_terminal(record.task.snapshot().status) { + stale.push(record.receipt.id) + } else { + unresolved = true + } + } + } + for id in stale { + self.reap(id) + } + if !unresolved { + self.active_operation = None + } + } + } +} + +///| +fn AgentTaskRuntime::begin_operation( + self : AgentTaskRuntime, + session_id : String, + run_id : @kernel.RunId, + turn_id : @kernel.TurnId, +) -> Unit raise @error.AgentError { + self.refresh_active_operation() + if self.active_operation is Some(_) { + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "foreground task cleanup is still pending", + ), + ) + } + self.active_operation = Some({ session_id, run_id, turn_id, }) +} + +///| +fn AgentTaskRuntime::next_receipt( + self : AgentTaskRuntime, + extension_id : String, + spec : @port.TaskSpec, +) -> @port.TaskReceipt { + let runtime_id = match self.runtime_id { + Some(value) => value + None => abort("task runtime nonce missing while scope is active") + } + let id = "agent_task_\{runtime_id}_\{self.next_id}" + self.next_id = self.next_id + 1 + { + id, + session_id: spec.session_id, + mode: spec.mode, + label: spec.label, + extension_id, + } +} + +///| +fn AgentTaskRuntime::submit( + self : AgentTaskRuntime, + extension_id : String, + spec : @port.TaskSpec, +) -> Result[@port.TaskHandle, @port.TaskSubmitError] { + if spec.session_id == "" { + return Err(InvalidSession(reason="session_id must not be empty")) + } + match spec.timeout_ms { + Some(timeout_ms) if timeout_ms <= 0 => + return Err(InvalidSession(reason="timeout_ms must be positive")) + _ => () + } + if self.closed { + return Err(Closed) + } + + let operation = self.active_operation + match spec.mode { + @port.TaskMode::Foreground => + match operation { + None => return Err(Unavailable) + Some(owner) if owner.session_id != spec.session_id => + return Err( + InvalidSession( + reason="foreground task session does not match the active operation", + ), + ) + Some(_) => () + } + @port.TaskMode::Background => () + } + + let supervisor = match self.supervisor { + Some(value) => value + None => return Err(Unavailable) + } + let receipt = self.next_receipt(extension_id, spec) + let runtime = self + let task = match + supervisor.spawn( + async fn() { runtime.execute(spec, receipt, operation) }, + label=receipt.label, + ) { + Ok(value) => value + Err(_) => return Err(Closed) + } + self.tasks[receipt.id] = { receipt, task, operation, } + let handle_task = task + Ok( + @port.TaskHandle::from_callbacks( + receipt~, + wait=async fn(_target) { + let waited : Result[@port.TaskOutcome, Error] = Ok(handle_task.wait()) catch { + error => Err(error) + } + let outcome = match waited { + Ok(value) => value + Err(error) if error is @async.WaitedTaskAlreadyCancelled => + @port.TaskOutcome::{ + receipt, + status: @port.TaskStatus::Cancelled(reason="task cancelled"), + } + Err(error) => + @port.TaskOutcome::{ + receipt, + status: @port.TaskStatus::Failed(reason=error.to_string()), + } + } + runtime.reap(receipt.id) + snapshot_task_outcome(outcome) + }, + cancel=fn(_target) { handle_task.cancel() }, + ), + ) +} + +///| +#warnings("-fragile_catch_all") +async fn AgentTaskRuntime::execute( + self : AgentTaskRuntime, + spec : @port.TaskSpec, + receipt : @port.TaskReceipt, + _operation : AgentTaskOperation?, +) -> @port.TaskOutcome { + let guarded : async () -> @kernel.Message = async fn() { + let message = (spec.run)() catch { + error => { + @async.pause() + raise error + } + } + @async.pause() + message + } + let status : @port.TaskStatus = match spec.timeout_ms { + Some(timeout_ms) => { + let result : Result[@kernel.Message?, Error] = Ok( + @async.handle_cancellation(async fn() { + @async.with_timeout(timeout_ms, () => guarded()) + }), + ) catch { + error => Err(error) + } + match result { + Ok(Some(message)) => + @port.TaskStatus::Completed(message=snapshot_message(message)) + Ok(None) => @port.TaskStatus::Cancelled(reason="task cancelled") + Err(error) if error is @async.TimeoutError => @port.TaskStatus::TimedOut + Err(error) => @port.TaskStatus::Failed(reason=error.to_string()) + } + } + None => { + let result : Result[@kernel.Message?, Error] = Ok( + @async.handle_cancellation(guarded), + ) catch { + error => Err(error) + } + match result { + Ok(Some(message)) => + @port.TaskStatus::Completed(message=snapshot_message(message)) + Ok(None) => @port.TaskStatus::Cancelled(reason="task cancelled") + Err(error) => @port.TaskStatus::Failed(reason=error.to_string()) + } + } + } + let outcome : @port.TaskOutcome = { receipt, status, } + match receipt.mode { + @port.TaskMode::Background => + self.enqueue_outcome(snapshot_task_outcome(outcome)) + @port.TaskMode::Foreground => () + } + outcome +} + +///| +fn AgentTaskRuntime::reap(self : AgentTaskRuntime, receipt_id : String) -> Unit { + self.tasks.remove(receipt_id) +} + +///| +fn AgentTaskRuntime::cancel_foreground( + self : AgentTaskRuntime, + operation : AgentTaskOperation, +) -> Unit { + for record in self.tasks.values() { + if self.record_matches_operation(record, operation) { + record.task.cancel() + } + } +} + +///| +fn AgentTaskRuntime::cancel_foreground_for_run( + self : AgentTaskRuntime, + run_id : @kernel.RunId, +) -> Unit { + match self.active_operation { + Some(operation) if operation.run_id == run_id => + self.cancel_foreground(operation) + _ => () + } +} + +///| +async fn AgentTaskRuntime::finish_active_operation( + self : AgentTaskRuntime, +) -> Unit raise @error.AgentError { + let operation = match self.active_operation { + None => return + Some(value) => value + } + let records = Array::from_iter(self.tasks.values()).filter(fn(record) { + self.record_matches_operation(record, operation) + }) + if records.is_empty() { + self.active_operation = None + return + } + let supervisor = match self.supervisor { + Some(value) => value + None => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "task supervisor is unavailable during foreground cleanup", + ), + ) + } + let tasks = records.map(fn(record) { record.task }) + let settled : Result[@fuwaroid.SettleOutcome, Error] = Ok( + @async.protect_from_cancel(async fn() { + supervisor.cancel_and_wait( + tasks=tasks.clamped_view(), + timeout_ms=self.foreground_settle_timeout_ms, + ) + }), + ) catch { + error => Err(error) + } + let outcome = match settled { + Ok(value) => value + Err(error) => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "foreground task settlement failed: " + error.to_string(), + ), + ) + } + match outcome { + @fuwaroid.Settled => { + for record in records { + self.reap(record.receipt.id) + } + self.active_operation = None + } + @fuwaroid.DeadlineExceeded(snapshots) => { + self.refresh_active_operation() + let detail = snapshots + .map(fn(snapshot) { + let label = if snapshot.label == "" { + "" + } else { + snapshot.label + } + "\{label}:\{repr(snapshot.status)}" + }) + .join(", ") + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "foreground task cleanup deadline exceeded: " + detail, + ), + ) + } + } +} + +///| +fn AgentTaskRuntime::enqueue_outcome( + self : AgentTaskRuntime, + outcome : @port.TaskOutcome, +) -> Unit { + let session_id = outcome.receipt.session_id + if self.outcomes.contains(session_id) { + let existing = self.outcomes[session_id] + if !existing.iter().any(fn(item) { item.receipt.id == outcome.receipt.id }) { + existing.push(outcome) + } + } else { + self.outcomes[session_id] = [outcome] + } +} + +///| +fn AgentTaskRuntime::has_inflight( + self : AgentTaskRuntime, + session_id : String, +) -> Bool { + match self.inflight.get(session_id) { + Some(ids) => !ids.is_empty() + None => false + } +} + +///| +fn AgentTaskRuntime::metadata_with_applied_outcomes( + self : AgentTaskRuntime, + session_id : String, + metadata : Map[String, Json], +) -> Map[String, Json] raise @error.AgentError { + let ids = parse_task_applied_ids(metadata) + match self.inflight.get(session_id) { + Some(inflight) => + for id in inflight { + if !ids.contains(id) { + ids.push(id) + } + } + None => () + } + if !ids.is_empty() { + metadata[task_outcomes_applied_metadata_key] = Json::array( + ids.map(fn(id) { Json::string(id) }), + ) + } + metadata +} + +///| +/// Parse the Agent-owned applied-outcome metadata once at each session +/// admission. Any malformed value is a runtime error before the turn can +/// perform model, memory, or task-outcome side effects. +fn parse_task_applied_ids( + metadata : Map[String, Json], +) -> Array[String] raise @error.AgentError { + match metadata.get(task_outcomes_applied_metadata_key) { + None => [] + Some(Array(values)) => { + let ids : Array[String] = [] + for value in values { + match value { + String(id) => ids.push(id) + _ => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "malformed posoco.task.applied_ids metadata entry", + ), + ) + } + } + ids + } + Some(_) => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "malformed posoco.task.applied_ids metadata value", + ), + ) + } +} + +///| +fn task_outcome_is_applied( + applied_ids : Array[String], + outcome : @port.TaskOutcome, +) -> Bool { + applied_ids.contains(outcome.receipt.id) +} + +///| +fn AgentTaskRuntime::peek_outcomes( + self : AgentTaskRuntime, + session_id : String, +) -> Array[@port.TaskOutcome] { + let pending : Array[@port.TaskOutcome] = match self.outcomes.get(session_id) { + Some(values) => values.map(snapshot_task_outcome) + None => [] + } + self.inflight[session_id] = pending.map(fn(outcome) { outcome.receipt.id }) + pending +} + +///| +/// Copy the message payload carried by a task terminal so queued delivery and +/// repeated handle waits cannot share mutable content with the worker result. +fn snapshot_task_outcome(outcome : @port.TaskOutcome) -> @port.TaskOutcome { + let status = match outcome.status { + @port.TaskStatus::Completed(message~) => + @port.TaskStatus::Completed(message=snapshot_message(message)) + @port.TaskStatus::Failed(reason~) => @port.TaskStatus::Failed(reason~) + @port.TaskStatus::TimedOut => @port.TaskStatus::TimedOut + @port.TaskStatus::Cancelled(reason~) => @port.TaskStatus::Cancelled(reason~) + } + { receipt: outcome.receipt, status, } +} + +///| +fn AgentTaskRuntime::commit_outcomes( + self : AgentTaskRuntime, + session_id : String, +) -> Unit { + let committed : Array[String] = match self.inflight.get(session_id) { + Some(ids) => ids + None => return + } + match self.outcomes.get(session_id) { + Some(values) => { + let remaining = values.filter(fn(outcome) { + !committed.contains(outcome.receipt.id) + }) + if remaining.is_empty() { + self.outcomes.remove(session_id) + } else { + self.outcomes[session_id] = remaining + } + for id in committed { + self.reap(id) + } + } + None => () + } + self.inflight.remove(session_id) +} + +///| +fn task_message_text(message : @kernel.Message) -> String { + let content_text = fn(content : @kernel.Content) -> String { + match content { + @kernel.Content::Text(text) => text + @kernel.Content::Image(media_type~, ..) => "[image=\{media_type}]" + } + } + match message { + @kernel.Message::SystemMessage(content~) => + content.map(content_text).join("") + @kernel.Message::UserMessage(content~) => content.map(content_text).join("") + @kernel.Message::AssistantMessage(content~, ..) => + content.map(content_text).join("") + @kernel.Message::ToolMessage(outcome~, ..) => outcome.summary() + } +} + +///| +fn task_outcome_marker(outcome : @port.TaskOutcome) -> String { + "[posoco-task-outcome id=\{outcome.receipt.id}]" +} + +///| +/// Outcomes enter the transcript as user/context data. This prevents a task +/// completion from gaining system or tool authority merely because it came +/// from an Agent-owned worker. +fn task_outcome_message(outcome : @port.TaskOutcome) -> @kernel.Message { + let marker = task_outcome_marker(outcome) + let detail = match outcome.status { + @port.TaskStatus::Completed(message~) => task_message_text(message) + @port.TaskStatus::Failed(reason~) => "failed: \{reason}" + @port.TaskStatus::TimedOut => "timed out" + @port.TaskStatus::Cancelled(reason~) => "cancelled: \{reason}" + } + @kernel.Message::UserMessage(content=[ + @kernel.Content::Text( + "\{marker} extension=\{outcome.receipt.extension_id} label=\{outcome.receipt.label}: \{detail}", + ), + ]) +} + +///| +fn AgentTaskRuntime::shutdown_straggler_detail( + self : AgentTaskRuntime, +) -> String { + let details : Array[String] = [] + for record in self.tasks.values() { + let snapshot = record.task.snapshot() + if !supervised_task_terminal(snapshot.status) { + let label = if record.receipt.label == "" { + "" + } else { + record.receipt.label + } + details.push( + "extension=\{record.receipt.extension_id} label=\{label} receipt=\{record.receipt.id} status=\{repr(snapshot.status)}", + ) + } + } + if details.is_empty() { + "" + } else { + details.join(", ") + } +} + +///| +/// Close task admission and bounded-settle all live managed work. +/// +/// The deadline bounds Posoco's settlement observation only. Managed tasks +/// remain children of the owning async TaskGroup, so a worker that ignores +/// cooperative cancellation can still keep the structured scope alive after +/// this method reports the offending task. +async fn AgentTaskRuntime::shutdown( + self : AgentTaskRuntime, +) -> Unit raise @error.AgentError { + if !self.closed { + self.closed = true + self.scope_active = false + } + let supervisor = match self.supervisor { + Some(value) => value + None => { + self.active_operation = None + return + } + } + let settled : Result[@fuwaroid.SettleOutcome, Error] = Ok( + @async.protect_from_cancel(async fn() { + supervisor.shutdown(timeout_ms=self.shutdown_settle_timeout_ms) + }), + ) catch { + error => Err(error) + } + let outcome = match settled { + Ok(value) => value + Err(error) => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "managed task shutdown settlement failed: " + error.to_string(), + ), + ) + } + match outcome { + @fuwaroid.Settled => { + let records : Array[AgentTaskRecord] = Array::from_iter( + self.tasks.values(), + ) + for record in records { + self.reap(record.receipt.id) + } + self.supervisor = None + self.active_operation = None + } + @fuwaroid.DeadlineExceeded(_) => + raise @error.AgentError::Runtime( + @error.RuntimeError::InvocationFailed( + "managed task shutdown deadline exceeded: " + + self.shutdown_straggler_detail(), + ), + ) + } +} diff --git a/src/agent/agent_turn.mbt b/src/agent/agent_turn.mbt new file mode 100644 index 0000000..6d433a2 --- /dev/null +++ b/src/agent/agent_turn.mbt @@ -0,0 +1,1352 @@ +///| +/// Per-turn execution: policy assembly, the Puppet prompt call, terminal +/// translation (fork / compact / failure), and transcript persistence. + +///| +/// Format a quota verdict's scheduling data (`ModelFailure::RateLimited` +/// shape) in the `ModelError::RateLimited` label style, so the AgentError +/// message carries the provider code and reset time verbatim. +fn quota_error_detail( + message : String, + reset_at_ms : Int64?, + provider_code : String?, +) -> String { + let code_label = match provider_code { + Some(code) => code + None => "none" + } + let reset_label = match reset_at_ms { + Some(at) => "\{at}" + None => "none" + } + "code=\{code_label}, reset_at_ms=\{reset_label}, message=\{message}" +} + +///| +/// True when `left` and `right` share the same first `len` messages. +fn messages_prefix_equal( + left : Array[@kernel.Message], + right : Array[@kernel.Message], + len : Int, +) -> Bool { + if left.length() < len || right.length() < len { + return false + } + for i in 0.. Bool { + if left.length() != right.length() { + return false + } + for key in left.keys() { + match right.get(key) { + Some(value) if value == left[key] => () + _ => return false + } + } + true +} + +///| +fn automatic_session_title(input : @kernel.Message) -> String? { + let raw = match input { + UserMessage(content~) => { + let buf = StringBuilder() + let mut has_text = false + for item in content { + match item { + Text(text) => { + if has_text { + buf.write_char(' ') + } + buf.write_string(text) + has_text = true + } + Image(..) => () + } + } + buf.to_string() + } + _ => return None + } + let out = StringBuilder() + let mut pending_space = false + let mut written = 0 + for ch in raw { + let code = ch.to_int() + if code == 0x20 || code == 0x09 || code == 0x0a || code == 0x0d { + pending_space = true + } else { + if pending_space && written > 0 && written < 50 { + out.write_char(' ') + written = written + 1 + } + pending_space = false + if written >= 50 { + break + } + out.write_char(ch) + written = written + 1 + } + } + let title = out.to_string() + if title.length() == 0 { + None + } else { + Some(title) + } +} + +///| +fn session_metadata_for_admission( + session : @types.Session, + input : @kernel.Message, +) -> Map[String, Json] { + let metadata = snapshot_metadata(session.metadata) + if session.messages.is_empty() && + !metadata.contains(@types.SESSION_TITLE_METADATA_KEY) { + match automatic_session_title(input) { + Some(title) => + metadata[@types.SESSION_TITLE_METADATA_KEY] = Json::string(title) + None => () + } + } + metadata +} + +///| +/// Memory inbound injection: on a session's first turn (empty loaded +/// transcript) every memory source gets exactly one `inbound` read; the +/// attempt is spent even when nothing comes back (freeze-even-if-empty), +/// and any bodies freeze as ONE user message placed before the input — so +/// the input stays at index `current_turn_start`, keeping the pure-append +/// save math and fork/compact slicing intact. +async fn AgentRuntime::inject_memory_inbound( + self : AgentRuntime, + session : @types.Session, + session_id : String, + input : @kernel.Message, + messages : Array[@kernel.Message], +) -> Unit noraise { + if session.messages.length() == 0 && + !self.memory.is_empty() && + !self.memory_inbound_attempted.contains(session_id) { + self.memory_inbound_attempted[session_id] = true + let request = match input { + UserMessage(content~) => { + let parts : Array[String] = [] + for item in content { + match item { + Text(t) => parts.push(t) + _ => () + } + } + parts.join("") + } + _ => "" + } + let bodies = collect_memory_inbound( + self.memory, + session_id, + request, + self.config.memory_inbound_timeout_ms, + fn(reason) { + emit_secondary_failure(self.observers, "memory_inbound", reason) + }, + ) + if !bodies.is_empty() { + messages.push(memory_inbound_message(bodies)) + } + } +} + +///| +/// Drive one full turn through the Puppet. Steps: +/// 0. refresh the catalog when a CatalogSource's revision changed +/// 1. load session +/// 2. snapshot session.messages (deep copy) + append input +/// 3. (identity minted by the caller, before TurnStarted — see +/// `AgentRuntime::mint_turn_identity`) +/// 4. drive the Agent-owned long-lived Puppet +/// 5. translate non-Accepted PromptResult → AgentError +/// 6. materialize and save the canonical terminal transcript (write-all) +/// 7. return TurnResult +async fn AgentRuntime::run_turn_via_puppet( + self : AgentRuntime, + input : @kernel.Message, + session_id : String, + identity : TurnIdentity, + resume_persisted_user : Bool, +) -> @types.TurnResult raise @error.AgentError { + // Step 0: catalog refresh at the prompt boundary. No run is active yet, + // so a swap cannot disturb an in-flight run; a failed rebuild keeps the + // previous snapshot and is surfaced as a secondary failure. + self.refresh_catalog_if_changed() + + let session = self.load_session_for_operation(session_id) + let applied_task_ids = parse_task_applied_ids(session.metadata) + match @types.ContextState::from_metadata(session.metadata) { + Some(state) => + match self.puppet.session_context_state(session_id) { + Some(_) => () + None => self.puppet.adopt_context_state(session_id, state) + } + None => () + } + self.project_context_state(Some(identity.scope()), session_id) + + // Step 2: snapshot + seed this turn. The window is fixated so persisted + // dangling tool calls replay wire-valid. Recovery replays a persisted + // trailing user message without appending it again; every other path + // appends the supplied input. Deep-copy so callers cannot mutate recorded + // state. + let raw = snapshot_messages(session.messages) + let messages = fixate_pending_tool_calls(raw) + self.session_cursors[session_id] = fixated_persisted_boundary(raw, messages) + if !resume_persisted_user { + let pending = self.task_runtime.peek_outcomes(session_id) + for outcome in pending { + if !task_outcome_is_applied(applied_task_ids, outcome) { + messages.push(task_outcome_message(outcome)) + } + } + } + let (input, current_turn_start) = match messages.last() { + Some(last) if resume_persisted_user && last is UserMessage(..) => + (snapshot_message(last), messages.length() - 1) + _ => { + self.inject_memory_inbound(session, session_id, input, messages) + let current_turn_start = messages.length() + messages.push(snapshot_message(input)) + (input, current_turn_start) + } + } + // Session display state is admitted from the caller's real input before + // memory/hooks can inject additional model-facing UserMessages. + let current_metadata = session_metadata_for_admission(session, input) + let admission_metadata : Map[String, Json]? = if session_metadata_equal( + current_metadata, + session.metadata, + ) { + None + } else { + Some(current_metadata) + } + let cursor = match self.session_cursors.get(session_id) { + Some(value) => value + None => 0 + } + let admission_tail = if cursor < messages.length() { + snapshot_messages(messages[cursor:].to_owned()) + } else { + [] + } + if !admission_tail.is_empty() || admission_metadata is Some(_) { + self.checkpoint_sessions_all( + session_id, cursor, admission_tail, admission_metadata, "input_checkpoint", + ) + } + + // Step 3: per-prompt policy on the caller-minted identity. The counter is + // Agent-owned and monotonic, so two calls on the same Agent never share + // run/turn ids while still using the same Puppet and journal. + let budget : @kernel_exec.Budget = { + max_model_steps: None, + max_tool_rounds: self.config.max_tool_rounds, + max_tool_calls: None, + max_total_tokens: None, + } + // Live capability read: the modelport self-reports the active model's + // context window / compact threshold (the router forwards the active + // slot's values). Precedence is defined exactly once — host config > + // provider report > core default (0.88) — and is read per turn so `/model` + // switches take effect on the next turn without recomposing the Agent. + let live = self.model.provider_config() + let policy : @puppetry.RunPolicy = { + model_id: "agent", + call_options: self.config.to_call_options_json(), + budget, + context_window: match self.config.model_context_window { + Some(window) => Some(window) + None => live.context_window + }, + compact_threshold: match self.config.compact_threshold { + Some(threshold) => threshold + None => + match live.compact_threshold { + Some(threshold) => threshold + None => 0.88 + } + }, + } + let request : @puppetry.PromptRequest = { + run_id: identity.run_id, + turn_id: identity.turn_id, + session_id: identity.session_id, + initial_messages: messages, + operation_id: "agent_run_turn_\{identity.seq}", + policy, + } + + // Step 4: drive the already-started, Agent-owned Puppet. The chunk + // dispatcher is per-turn: its drain task runs for the duration of the turn + // and is reaped by `with_task_group` when the turn body returns or raises. + // The queue MUST be closed inside the group closure: `with_task_group` + // returns only after every child task (the drain loop included) has + // terminated, and the drain loop terminates only when the queue closes — + // closing after the group returns would deadlock. + let dispatcher = ChunkDispatcher(self.observers) + self.chunk_dispatcher.val = Some(dispatcher) + // The typed error is captured inside the group closure and re-raised + // after the boundary: `with_task_group`'s closure signature carries the + // wide `Error` (no error type parameter), so capturing the typed value is + // the only way the original `AgentError` variant — and its contextualized + // messages — survives the crossing. + let turn_error : Ref[@error.AgentError?] = Ref(None) + let result = @async.with_task_group(async fn(group) { + group.spawn_bg(async fn() { dispatcher.drain_loop() }) + errdefer { + dispatcher.flush() + dispatcher.close() + } + // The Ok/Err wrapper lets the typed AgentError escape the group closure + // as data: catch + re-raise is fragile_catch_all and try? is deprecated. + // The body runs under handle_cancellation (async 0.22: a raw cancellation + // signal bypasses catch) and returns its typed outcome as data, so the + // wide raise crossing cannot erase the AgentError classification. + let outcome : Result[@types.TurnResult, @error.AgentError]? = @async.handle_cancellation(async fn() { + let typed : Result[@types.TurnResult, @error.AgentError] = Ok( + self.run_puppet_turn_body( + request, identity, session_id, input, current_turn_start, current_metadata, + ), + ) catch { + e => Err(e) + } + typed + }, + ) + let turn_result = match outcome { + Some(Ok(r)) => r + Some(Err(e)) => { + turn_error.val = Some(e) + raise e + } + None => { + let cancelled : @error.AgentError = @error.AgentError::Cancelled( + "turn cancelled", + ) + turn_error.val = Some(cancelled) + raise cancelled + } + } + dispatcher.flush() + dispatcher.close() + turn_result + }) catch { + _ => { + self.chunk_dispatcher.val = None + let original : @error.AgentError = match turn_error.val { + Some(ae) => ae + // Cancellation is translated inside the group closure, so any error + // reaching here is an ordinary defect with no typed classification. + None => Runtime(InvocationFailed("turn body: unclassified error")) + } + raise original + } + } + self.chunk_dispatcher.val = None + result +} + +///| +/// Body of one Puppet turn: drive the Puppet, translate the result, and +/// materialize the `TurnResult`. Extracted from `run_turn_via_puppet` so the +/// dispatcher lifecycle (create / spawn drain / flush / close) can wrap it +/// uniformly on both the success and failure paths. +async fn AgentRuntime::run_puppet_turn_body( + self : AgentRuntime, + request : @puppetry.PromptRequest, + identity : TurnIdentity, + session_id : String, + input : @kernel.Message, + current_turn_start : Int, + current_metadata : Map[String, Json], +) -> @types.TurnResult raise @error.AgentError { + // Step 4: drive the already-started, Agent-owned Puppet. + let prompt_result = self.puppet.prompt(request) catch { + e => + // A cancellation inside the prompt travels as a runtime signal and is + // translated at the turn boundary; only ordinary defects land here. + raise Runtime( + InvocationFailed("puppet prompt: " + safe_error_label(e.to_string())), + ) + } + + // Step 5: handle non-Accepted prompt results. + match prompt_result { + Rejected(error=HookRejected(reason~)) => raise PipelineAborted(reason) + Rejected(error=PumpLoopExceeded(..)) => + // With a correctly sized backstop (2 * rounds + 1) the kernel budget + // always fires first; reaching this branch means a livelock or an + // internal accounting defect, not a budget hit. + raise Runtime( + InvocationFailed("pump loop exceeded (internal livelock guard)"), + ) + Rejected(error=ReducerInvariantViolation(..)) => + raise Runtime(InvocationFailed("puppet invariant violation")) + Rejected(error=EffectExecutionFailed(phase="auto_compact", cause~)) => { + match self.puppet.last_completed_transcript() { + Some(t) => { + let operation = transcript_save_operation( + t, + request.initial_messages, + current_turn_start, + ) + let saved : Result[Unit, @error.AgentError] = Ok( + self.save_turn_transcript( + session_id, + t, + current_metadata, + request.initial_messages, + current_turn_start, + ), + ) catch { + error => Err(error) + } + match saved { + Ok(_) => self.task_runtime.commit_outcomes(session_id) + Err(error) => + emit_secondary_failure( + self.observers, + "failed_turn_transcript_save", + "session_id=\{safe_error_label(session_id)};op=\{operation};cause=\{failed_save_cause_label(error)}", + ) + } + } + None => () + } + raise @error.AgentError::AutoCompactFailed(cause) + } + Rejected(error=EffectExecutionCancelled(phase~)) => + raise @error.AgentError::Cancelled( + "effect execution cancelled in \{phase}", + ) + Rejected(error=EffectExecutionFailed(cause~, phase~)) => { + // Compact causes front-load the allowlisted provider fields; keep them. + let cause_label = if is_compact_effect_phase(phase) { + bounded_error_label(cause, 512) + } else { + safe_error_label(cause) + } + raise Runtime(InvocationFailed("puppet effect execution: " + cause_label)) + } + Rejected(error~) => + raise Runtime( + InvocationFailed("puppet rejected the prompt: " + error.to_string()), + ) + ForkCompleted(parent_session_id~, new_session_id~, forked_messages~, ..) => + return self.complete_fork_turn( + identity, parent_session_id, new_session_id, forked_messages, input, + ) + CompactCompleted(mode~, new_session_id~, compacted_messages~, ..) => + return self.complete_compact_turn( + identity, session_id, mode, new_session_id, compacted_messages, input, current_metadata, + ) + Accepted(..) => { + // Check terminal outcome for model failures. + let failure : @error.AgentError? = self.terminal_failure_to_agent_error() + match failure { + Some(err) => { + // A failed turn still owns its transcript: the user input and any + // completed progress must reach the session store, or the next turn + // reloads pre-turn state and the model loses the context of what it + // was doing — and what the user asked. + match self.puppet.last_completed_transcript() { + Some(t) => { + let operation = transcript_save_operation( + t, + request.initial_messages, + current_turn_start, + ) + let saved : Result[Unit, @error.AgentError] = Ok( + self.save_turn_transcript( + session_id, + t, + current_metadata, + request.initial_messages, + current_turn_start, + ), + ) catch { + error => Err(error) + } + match saved { + Ok(_) => self.task_runtime.commit_outcomes(session_id) + Err(error) => + // The turn's own failure is the primary signal; a + // secondary save failure must remain observable. + emit_secondary_failure( + self.observers, + "failed_turn_transcript_save", + "session_id=\{safe_error_label(session_id)};op=\{operation};cause=\{failed_save_cause_label(error)}", + ) + } + } + None => () + } + raise err + } + None => () + } + } + LeaseBusy(..) => raise Runtime(InvocationFailed("puppet lease busy")) + } + + // Step 6: materialize the terminal transcript for persistence and the + // TurnResult. Observer projection already happened from committed envelopes; + // this traversal has no side effects and only selects this turn's result. + let final_transcript = match self.puppet.last_completed_transcript() { + Some(t) => t + None => + raise Runtime( + InvocationFailed("puppet did not produce a terminal transcript"), + ) + } + let transcript_msgs = final_transcript.messages_snapshot() + let mut final_message : @kernel.Message = input + let all_results : Array[@kernel.ToolOutcome] = [] + for i in current_turn_start.. final_message = message + ToolMessage(outcome~, ..) => all_results.push(outcome) + _ => () + } + } + + // Save session (write-all, with carried metadata). + self.save_turn_transcript( + session_id, + final_transcript, + current_metadata, + request.initial_messages, + current_turn_start, + ) + self.task_runtime.commit_outcomes(session_id) + self.project_context_state(Some(identity.scope()), session_id) + + // Step 7: build TurnResult. + let result : @types.TurnResult = { + message: final_message, + tool_results: all_results, + final_session_id: session_id, + } + result +} + +///| +/// Translate the terminal outcome of an Accepted run into the turn's primary +/// AgentError, or None when the turn completed. +fn AgentRuntime::terminal_failure_to_agent_error( + self : AgentRuntime, +) -> @error.AgentError? { + match self.puppet.last_terminal_outcome() { + Some(Failed(reason=ModelTransport(detail~))) => + Some(Model("model transport: " + detail)) + Some(Failed(reason=ModelParseFailure(detail~))) => + Some(Model("model parse failure: " + detail)) + Some(Failed(reason=ModelRuntime(detail~))) => + Some(Model("model runtime failure: " + detail)) + Some( + Failed(reason=ModelQuotaExhausted(detail~, reset_at_ms~, provider_code~)) + ) => + // Provider quota/rate-limit verdict. The message keeps the provider + // code and reset time so hosts can schedule a retry instead of + // parsing the provider excerpt for scheduling data. + Some( + Model( + "model quota exhausted: " + + quota_error_detail(detail, reset_at_ms, provider_code), + ), + ) + Some(Failed(reason=HostRejected(detail~))) => + Some(Runtime(InvocationFailed("host rejected: " + detail))) + Some(Failed(reason=InvariantViolation(category~))) => + Some( + Runtime( + InvocationFailed( + "kernel invariant violation: " + category.to_string(), + ), + ), + ) + Some(Failed(reason=DeadlineReached)) => + Some(Runtime(InvocationFailed("deadline reached"))) + Some(Failed(reason=BudgetExhausted(dimension=ToolRounds))) => + // The reducer rejects exactly the batch that would push consumed + // to limit + 1, so consumed is derivable; Agent only ever wires + // the ToolRounds dimension (other budget dims are None here). + match self.config.max_tool_rounds { + Some(limit) => Some(ToolLoopExceeded(consumed=limit + 1, limit~)) + None => + Some( + Runtime( + InvocationFailed( + "tool-round budget exhausted without a configured limit", + ), + ), + ) + } + Some(Failed(reason=BudgetExhausted(..))) => + Some(Runtime(InvocationFailed("budget exhausted"))) + Some(Cancelled(reason~)) => + Some(@error.AgentError::Cancelled("run cancelled: " + reason.to_string())) + _ => None + } +} + +///| +/// Fork terminal state (M3.5): fork creates a new session and stops the +/// current turn. The original thread is untouched; product code switches its +/// active thread to `new_session_id`. We persist the new session to every +/// configured store (write-all) and return a TurnResult whose +/// `final_session_id` is the ORIGINAL session — the caller stays on the +/// original thread because this `run_turn` was for the original. The forked +/// session is a sibling the caller can switch to next. +async fn AgentRuntime::complete_fork_turn( + self : AgentRuntime, + identity : TurnIdentity, + parent_session_id : String, + new_session_id : String, + forked_messages : Array[@kernel.Message], + input : @kernel.Message, +) -> @types.TurnResult raise @error.AgentError { + let forked_session : @types.Session = { + messages: snapshot_messages(forked_messages), + metadata: Map::from_array([]), + } + let with_lineage = forked_session.with_parent_thread_id(parent_session_id) + self.save_sessions_all( + new_session_id, + with_lineage.messages, + with_lineage.metadata, + "fork_save", + ) + self.session_cursors[new_session_id] = with_lineage.messages.length() + // Emit a SessionRedirect so observers know a sibling thread was + // created and can switch the UI. + let redirect : @types.TurnEvent = SessionRedirect( + from=parent_session_id, + to=new_session_id, + messages_before=forked_messages.length(), + messages_after=forked_messages.length(), + ) + for observer in self.observers { + observer.on_event_at(Some(identity.scope()), snapshot_turn_event(redirect)) + } + let result : @types.TurnResult = { + message: input, + tool_results: [], + final_session_id: parent_session_id, + } + result +} + +///| +/// Compact terminal state (M3.5): compact applied the modelport's +/// CompactResult. +/// - NewThread: persist the new session, return TurnResult with the NEW +/// final_session_id (the caller should switch active thread). +/// - Replace / Append: persist the current session with the updated +/// transcript body, return TurnResult with the ORIGINAL final_session_id. +async fn AgentRuntime::complete_compact_turn( + self : AgentRuntime, + identity : TurnIdentity, + session_id : String, + mode : @kernel.CompactMode, + new_session_id : String?, + compacted_messages : Array[@kernel.Message], + input : @kernel.Message, + current_metadata : Map[String, Json], +) -> @types.TurnResult raise @error.AgentError { + match mode { + NewThread => + match new_session_id { + Some(new_sid) => { + let new_session : @types.Session = { + messages: snapshot_messages(compacted_messages), + metadata: Map::from_array([]), + } + let with_lineage = new_session.with_parent_thread_id(session_id) + self.save_sessions_all( + new_sid, + with_lineage.messages, + with_lineage.metadata, + "compact_newthread_save", + ) + self.session_cursors[new_sid] = with_lineage.messages.length() + let redirect : @types.TurnEvent = SessionRedirect( + from=session_id, + to=new_sid, + messages_before=compacted_messages.length(), + messages_after=compacted_messages.length(), + ) + for observer in self.observers { + observer.on_event_at( + Some(identity.scope()), + snapshot_turn_event(redirect), + ) + } + let result : @types.TurnResult = { + message: input, + tool_results: [], + final_session_id: new_sid, + } + return result + } + None => + raise Runtime( + InvocationFailed( + "CompactCompleted::NewThread without new_session_id", + ), + ) + } + Replace | Append => { + // Save the updated original session body. The product caller + // stays on the same session id. + let updated_session : @types.Session = { + messages: snapshot_messages(compacted_messages), + metadata: snapshot_metadata(current_metadata), + } + self.save_sessions_all( + session_id, + updated_session.messages, + updated_session.metadata, + "compact_replace_save", + ) + self.session_cursors[session_id] = updated_session.messages.length() + let result : @types.TurnResult = { + message: input, + tool_results: [], + final_session_id: session_id, + } + return result + } + } +} + +///| +/// Commit one append-safe session boundary to every configured store. With no +/// SessionStore configured this is an intentional no-op: Agent execution is +/// ephemeral but otherwise fully functional. +async fn AgentRuntime::checkpoint_sessions_all( + self : AgentRuntime, + session_id : String, + from_index : Int, + messages : Array[@kernel.Message], + metadata : Map[String, Json]?, + operation : String, +) -> Unit raise @error.AgentError { + if self.sessions.is_empty() { + self.session_cursors[session_id] = from_index + messages.length() + return + } + let results : Array[Result[Unit, @error.SessionError]] = @async.all( + self.sessions.map(fn(store) { + () => { + let metadata_snapshot = match metadata { + Some(value) => Some(snapshot_metadata(value)) + None => None + } + let checkpoint : @port.SessionCheckpoint = { + from_index, + messages: snapshot_messages(messages), + metadata: metadata_snapshot, + } + Ok(store.checkpoint(session_id, checkpoint)) catch { + error => Err(error) + } + } + }), + ) catch { + error => raise save_error_context(error, session_id, operation) + } + for result in results { + match result { + Ok(_) => () + Err(error) => + raise contextualize_session_error(error, session_id, operation) + } + } + self.session_cursors[session_id] = from_index + messages.length() +} + +///| +/// Save one session body to every configured store (write-all). Each store +/// receives its own deep snapshot so a misbehaving store cannot mutate +/// another store's payload; `operation` labels both error contexts verbatim. +async fn AgentRuntime::save_sessions_all( + self : AgentRuntime, + session_id : String, + messages : Array[@kernel.Message], + metadata : Map[String, Json], + operation : String, +) -> Unit raise @error.AgentError { + let save_results : Array[Result[Unit, @error.SessionError]] = @async.all( + self.sessions.map(fn(store) { + () => { + let session = snapshot_session(messages, metadata) + Ok(store.save(session_id, session)) catch { + error => Err(error) + } + } + }), + ) catch { + error => raise save_error_context(error, session_id, operation) + } + for result in save_results { + match result { + Err(error) => + raise contextualize_session_error(error, session_id, operation) + Ok(_) => () + } + } +} + +///| +/// Select the persistence operation from the same prefix invariant used by +/// `save_turn_transcript`, so failure diagnostics identify the actual path. +fn transcript_save_operation( + transcript : @kernel.Transcript, + initial_messages : Array[@kernel.Message], + current_turn_start : Int, +) -> String { + let final_messages = transcript.messages_snapshot() + let initial_len = current_turn_start + 1 + if final_messages.length() >= initial_len && + messages_prefix_equal(final_messages, initial_messages, initial_len) { + "final_append" + } else { + "final_save" + } +} + +///| +/// Persist a terminal transcript to every session store (write-all, with +/// carried metadata). Shared by the completed and failed turn paths — a +/// failed turn's progress is exactly the context the next turn needs. +/// +/// Uses `append_messages` when the turn was a pure append (final transcript +/// starts with the same prefix that was loaded plus the new input); otherwise +/// falls back to a full save and resets the per-session cursor. The cursor is +/// always advanced to the final transcript length on success. +async fn AgentRuntime::save_turn_transcript( + self : AgentRuntime, + session_id : String, + transcript : @kernel.Transcript, + metadata : Map[String, Json], + initial_messages : Array[@kernel.Message], + current_turn_start : Int, +) -> Unit raise @error.AgentError { + let final_messages = snapshot_messages(transcript.messages_snapshot()) + let base_metadata = snapshot_metadata(metadata) + let metadata = self.metadata_with_context_state(session_id, metadata) + let metadata = self.task_runtime.metadata_with_applied_outcomes( + session_id, metadata, + ) + let metadata_checkpoint : Map[String, Json]? = if session_metadata_equal( + metadata, base_metadata, + ) { + None + } else { + Some(metadata) + } + let cursor = match self.session_cursors.get(session_id) { + Some(n) => n + None => 0 + } + let operation = if self.task_runtime.has_inflight(session_id) || + self.checkpoint_dirty.contains(session_id) { + "final_save" + } else { + transcript_save_operation(transcript, initial_messages, current_turn_start) + } + if operation == "final_append" { + let tail = if cursor < final_messages.length() { + snapshot_messages(final_messages[cursor:].to_owned()) + } else { + [] + } + if !tail.is_empty() || metadata_checkpoint is Some(_) { + self.checkpoint_sessions_all( + session_id, cursor, tail, metadata_checkpoint, "final_append", + ) + } + } else { + // Rewrite, fork, or compact: rewrite the whole persisted session. + self.save_sessions_all(session_id, final_messages, metadata, "final_save") + self.session_cursors[session_id] = final_messages.length() + } + self.checkpoint_dirty.remove(session_id) +} + +///| +/// Effect phases that belong to a compaction. Their failure causes are the +/// compact diagnostic carrier (provider status/code/param/request id), so +/// the Agent keeps the sanitized bounded head instead of re-truncating it. +/// Mirrors the puppetry twin in `compact_diagnostic.mbt` — extend both +/// together. +fn is_compact_effect_phase(phase : String) -> Bool { + phase == "compact" || phase == "compact_session" || phase == "auto_compact" +} + +///| +fn failed_save_cause_label(error : @error.AgentError) -> String { + match error { + Session(_) => "SessionError::Save" + Model(_) => "AgentError::Model" + Runtime(_) => "AgentError::Runtime" + ToolLoopExceeded(..) => "AgentError::ToolLoopExceeded" + PipelineAborted(_) => "AgentError::PipelineAborted" + Cancelled(_) => "AgentError::Cancelled" + AutoCompactFailed(_) => "AgentError::AutoCompactFailed" + } +} + +///| +fn AgentRuntime::metadata_with_context_state( + self : AgentRuntime, + session_id : String, + metadata : Map[String, Json], +) -> Map[String, Json] { + let metadata = snapshot_metadata(metadata) + match self.puppet.session_context_state(session_id) { + Some(state) => + metadata[@types.CONTEXT_STATE_METADATA_KEY] = state.to_metadata_json() + None => () + } + metadata +} + +///| +fn AgentRuntime::project_context_state( + self : AgentRuntime, + scope : @types.EventScope?, + session_id : String, +) -> Unit { + match self.puppet.session_context_state(session_id) { + Some(state) => self.emit_turn_event(scope, ContextStateUpdated(state~)) + None => () + } +} + +///| +async fn AgentRuntime::load_session_for_operation( + self : AgentRuntime, + session_id : String, +) -> @types.Session raise @error.AgentError { + match self.body_recovery.get(session_id) { + Some(pending) => { + ignore(parse_task_applied_ids(pending.metadata)) + self.save_sessions_all( + session_id, + pending.messages, + pending.metadata, + "compact_recovery_save", + ) + self.body_recovery.remove(session_id) + pending + } + None => + match self.sessions { + [first, ..] => { + let loaded = first.load(session_id) catch { + error => + raise contextualize_session_error(error, session_id, "load") + } + ignore(parse_task_applied_ids(loaded.metadata)) + loaded + } + [] => { + self.session_cursors[session_id] = 0 + { messages: [], metadata: Map::from_array([]), } + } + } + } +} + +///| +/// Repair a loaded window for wire replay: every assistant `tool_call` must +/// have a matching ToolMessage. Each unanswered call gets a synthesized +/// `RuntimeFailure(Interrupted)` output inserted immediately after its owning +/// assistant message (one message's outputs grouped together, in tool_calls +/// order), so adjacency holds even when later user input follows. Shared by +/// the turn path and the compact path. +fn fixate_pending_tool_calls( + messages : Array[@kernel.Message], +) -> Array[@kernel.Message] { + let answered : Map[String, Bool] = Map::from_array([]) + for message in messages { + match message { + ToolMessage(call_id~, ..) => answered[call_id.to_string()] = true + _ => () + } + } + let out : Array[@kernel.Message] = [] + for message in messages { + out.push(message) + match message { + AssistantMessage(tool_calls~, ..) => + for call in tool_calls { + if !answered.contains(call.call_id.to_string()) { + answered[call.call_id.to_string()] = true + out.push( + ToolMessage( + call_id=call.call_id, + tool_name=call.name, + outcome=RuntimeFailure( + error_category="Interrupted", + message="tool call was pending when the previous operation ended; recorded as interrupted", + ), + ), + ) + } + } + _ => () + } + } + out +} + +///| +/// Append-save cursor for a fixated window: the fixated index just past the +/// last persisted (raw) message. Synthesized outputs interleaved before that +/// point cannot cross a concatenating append, so they stay a per-load +/// derivation; outputs owned by the trailing persisted message ride the +/// appended tail and become persisted. Returns 0 for an empty raw window. +fn fixated_persisted_boundary( + raw : Array[@kernel.Message], + fixated : Array[@kernel.Message], +) -> Int { + let mut i = 0 + let mut matched = 0 + while matched < raw.length() && i < fixated.length() { + if fixated[i] == raw[matched] { + matched = matched + 1 + } + i = i + 1 + } + i +} + +///| +async fn AgentRuntime::compact_session( + self : AgentRuntime, + session_id : String, +) -> @types.CompactOutcome raise @error.AgentError { + if self.turn_active { + raise Runtime(InvocationFailed("agent turn busy")) + } + self.turn_active = true + // Same unwind guard as run_turn_with_mode: a hard-cancelled compact must + // not wedge the Agent busy. + errdefer { + self.turn_active = false + } + let result : Result[@types.CompactOutcome, @error.AgentError] = Ok( + self.run_compact_operation(session_id), + ) catch { + error => Err(error) + } + let cleanup : Result[Unit, @error.AgentError] = Ok( + self.task_runtime.finish_active_operation(), + ) catch { + error => Err(error) + } + self.turn_active = false + match result { + Ok(value) => + match cleanup { + Ok(_) => value + Err(error) => { + emit_secondary_failure( + self.observers, + "foreground_task_cleanup", + error.to_string(), + ) + raise error + } + } + Err(error) => { + match cleanup { + Err(cleanup_error) => + emit_secondary_failure( + self.observers, + "foreground_task_cleanup", + cleanup_error.to_string(), + ) + Ok(_) => () + } + raise error + } + } +} + +///| +#warnings("-fragile_catch_all") +async fn AgentRuntime::run_compact_operation( + self : AgentRuntime, + session_id : String, +) -> @types.CompactOutcome raise @error.AgentError { + self.ensure_agent_running() + self.notify_start_if_first() + let identity = self.mint_turn_identity(session_id) + self.task_runtime.begin_operation( + session_id, + identity.run_id, + identity.turn_id, + ) + let scope = identity.scope() + let session = self.load_session_for_operation(session_id) + self.session_cursors[session_id] = session.messages.length() + match @types.ContextState::from_metadata(session.metadata) { + Some(state) => + match self.puppet.session_context_state(session_id) { + Some(_) => () + None => self.puppet.adopt_context_state(session_id, state) + } + None => () + } + self.project_context_state(Some(scope), session_id) + let messages = fixate_pending_tool_calls(snapshot_messages(session.messages)) + let live = self.model.provider_config() + let policy : @puppetry.RunPolicy = { + model_id: "agent", + call_options: self.config.to_call_options_json(), + budget: { + max_model_steps: None, + max_tool_rounds: self.config.max_tool_rounds, + max_tool_calls: None, + max_total_tokens: None, + }, + context_window: match self.config.model_context_window { + Some(window) => Some(window) + None => live.context_window + }, + compact_threshold: match self.config.compact_threshold { + Some(threshold) => threshold + None => + match live.compact_threshold { + Some(threshold) => threshold + None => 0.88 + } + }, + } + self.emit_turn_event(Some(scope), CompactStarted(trigger="manual")) + let prompt_result = match + (Ok( + @async.handle_cancellation(async fn() { + self.puppet.compact_standalone( + identity.run_id, + identity.turn_id, + identity.session_id, + messages, + policy, + "agent_compact_session_\{identity.seq}", + Manual, + ) + }), + ) catch { + e => Err(e) + }) { + Ok(Some(result)) => result + Ok(None) => { + // The compact operation itself was cancelled: project the cancelled + // terminal and surface the structured outcome. + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="cancelled", + detail="compact cancelled", + ), + ) + raise @error.AgentError::Cancelled("compact cancelled") + } + Err(e) => + raise Runtime( + InvocationFailed("puppet compact: " + safe_error_label(e.to_string())), + ) + } + match prompt_result { + Rejected(error=EffectExecutionCancelled(..)) => { + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="cancelled", + detail="compact cancelled", + ), + ) + raise @error.AgentError::Cancelled("compact cancelled") + } + Rejected(error~) => { + // Keep the per-field-sanitized rendering; stage and provider fields + // must survive to the observer event and the raised error. + let detail = bounded_error_label(error.to_string(), 512) + self.emit_turn_event( + Some(scope), + OperationFinalized(operation="compact", outcome="failed", detail~), + ) + raise Runtime(InvocationFailed("puppet rejected the compact: " + detail)) + } + CompactCompleted(mode~, trigger~, new_session_id~, compacted_messages~, ..) => + match mode { + NewThread => + match new_session_id { + Some(new_sid) => { + let new_session : @types.Session = { + messages: snapshot_messages(compacted_messages), + metadata: Map::from_array([]), + } + let with_lineage = new_session.with_parent_thread_id(session_id) + self.save_sessions_all( + new_sid, + with_lineage.messages, + with_lineage.metadata, + "compact_newthread_save", + ) + self.session_cursors[new_sid] = with_lineage.messages.length() + let redirect : @types.TurnEvent = SessionRedirect( + from=session_id, + to=new_sid, + messages_before=compacted_messages.length(), + messages_after=compacted_messages.length(), + ) + for observer in self.observers { + observer.on_event_at(Some(scope), snapshot_turn_event(redirect)) + } + self.project_context_state(Some(scope), new_sid) + self.emit_turn_event( + Some(scope), + CompactFinished( + trigger=trigger.to_string(), + mode="NewThread", + final_session_id=new_sid, + messages_after=compacted_messages.length(), + ), + ) + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="completed", + detail="", + ), + ) + let outcome : @types.CompactOutcome = { + final_session_id: new_sid, + mode: NewThread, + messages_after: compacted_messages.length(), + } + outcome + } + None => { + let detail = "CompactCompleted::NewThread without new_session_id" + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="failed", + detail~, + ), + ) + raise Runtime(InvocationFailed(detail)) + } + } + Replace | Append => { + let updated_metadata = self.metadata_with_context_state( + session_id, + session.metadata, + ) + let updated_session : @types.Session = { + messages: snapshot_messages(compacted_messages), + metadata: updated_metadata, + } + let saved : Result[Unit, Error] = Ok( + self.save_sessions_all( + session_id, + updated_session.messages, + updated_session.metadata, + "compact_body_save", + ), + ) catch { + e => Err(e) + } + match saved { + Err(_) => { + self.body_recovery[session_id] = updated_session + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="failed", + detail="journal committed; body save failed; next operation repairs the projection", + ), + ) + raise contextualize_session_error( + @error.Conflict( + "compact committed but body save failed; session needs recovery", + ), + session_id, + "compact_body_save", + ) + } + Ok(_) => () + } + self.body_recovery.remove(session_id) + self.session_cursors[session_id] = updated_session.messages.length() + self.project_context_state(Some(scope), session_id) + self.emit_turn_event( + Some(scope), + CompactFinished( + trigger=trigger.to_string(), + mode=mode.to_string(), + final_session_id=session_id, + messages_after=updated_session.messages.length(), + ), + ) + self.emit_turn_event( + Some(scope), + OperationFinalized( + operation="compact", + outcome="completed", + detail="", + ), + ) + let outcome : @types.CompactOutcome = { + final_session_id: session_id, + mode, + messages_after: updated_session.messages.length(), + } + outcome + } + } + Accepted(..) | ForkCompleted(..) | LeaseBusy(..) => { + let detail = "unexpected prompt result from standalone compact" + self.emit_turn_event( + Some(scope), + OperationFinalized(operation="compact", outcome="failed", detail~), + ) + raise Runtime(InvocationFailed(detail)) + } + } +} diff --git a/src/agent/moon.pkg b/src/agent/moon.pkg new file mode 100644 index 0000000..7c8c54e --- /dev/null +++ b/src/agent/moon.pkg @@ -0,0 +1 @@ +package "colmugx/posoco/agent" diff --git a/src/builtin/builtin.mbt b/src/builtin/builtin.mbt new file mode 100644 index 0000000..561327b --- /dev/null +++ b/src/builtin/builtin.mbt @@ -0,0 +1,337 @@ +///| +pub fn build_tool_result_message( + call : @kernel.ToolCall, + result : @kernel.ToolOutcome, +) -> @kernel.Message { + let content : String = match result { + Success(content~, ..) => content + SuccessWithAttachments(content~, ..) => content + ToolReportedError(content~, ..) => content + RuntimeFailure(message~, ..) => message + NotExecuted(reason~, ..) => reason.to_string() + } + ToolMessage( + call_id=call.call_id, + tool_name=call.name, + outcome=Success(content~, structured=None), + ) +} + +///| +pub fn build_error_message( + call : @kernel.ToolCall, + error : @error.RuntimeError, +) -> @kernel.Message { + ToolMessage( + call_id=call.call_id, + tool_name=call.name, + outcome=RuntimeFailure( + error_category="RuntimeError", + message=error.to_string(), + ), + ) +} + +///| +/// Internal: merges multiple providers' tool lists. Agent routes via ToolRouting instead. +pub(all) struct CompositeToolProvider { + providers : Array[&@port.ToolProvider] +} + +///| +pub extend CompositeToolProvider with @port.ToolProvider::{execute, list_tools} + +///| +pub impl @port.ToolProvider for CompositeToolProvider with fn list_tools(self) { + let all : Array[@kernel.ToolDef] = [] + for p in self.providers { + for t in p.list_tools() { + all.push(t) + } + } + all +} + +///| +pub impl @port.ToolProvider for CompositeToolProvider with fn execute( + _self, + name : String, + _call : @kernel.ToolCall, +) -> @kernel.ToolOutcome raise @error.RuntimeError { + raise UnknownTool( + "CompositeToolProvider does not route execute(); Agent uses ToolRouting instead. Tool: '\{name}'", + ) +} + +///| +/// Dynamic runtime tool registration. Implements ToolProvider. +pub(all) struct ToolRegistry { + mut tools : Map[String, @kernel.ToolDef] + mut executors : Map[String, (@kernel.ToolCall) -> @kernel.ToolOutcome] +} + +///| +pub fn ToolRegistry::ToolRegistry() -> ToolRegistry { + { tools: Map::from_array([]), executors: Map::from_array([]), } +} + +///| +/// 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. +pub fn ToolRegistry::register( + self : ToolRegistry, + tool : @kernel.ToolDef, + executor : (@kernel.ToolCall) -> @kernel.ToolOutcome, +) -> Unit { + self.tools[tool.name.to_string()] = tool + self.executors[tool.name.to_string()] = executor +} + +///| +/// 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. +pub fn ToolRegistry::register_strict( + self : ToolRegistry, + tool : @kernel.ToolDef, + executor : (@kernel.ToolCall) -> @kernel.ToolOutcome, +) -> Unit raise @error.RuntimeError { + let key = tool.name.to_string() + if self.tools.contains(key) { + raise ToolAlreadyRegistered("tool '\{key}' is already registered") + } + self.tools[key] = tool + self.executors[key] = executor +} + +///| +pub fn ToolRegistry::unregister(self : ToolRegistry, name : String) -> Unit { + self.tools.remove(name) + self.executors.remove(name) +} + +///| +pub extend ToolRegistry with @port.ToolProvider::{execute, list_tools} + +///| +pub impl @port.ToolProvider for ToolRegistry with fn list_tools(self) { + let all : Array[@kernel.ToolDef] = [] + for v in self.tools.values() { + all.push(v) + } + all +} + +///| +pub impl @port.ToolProvider for ToolRegistry with fn execute( + self, + name : String, + call : @kernel.ToolCall, +) -> @kernel.ToolOutcome raise @error.RuntimeError { + match self.executors.get(name) { + Some(exec) => exec(call) + None => raise UnknownTool("No executor registered for tool '\{name}'") + } +} + +///| +pub(all) struct NoopLifecycle {} + +///| +pub extend NoopLifecycle with @port.Lifecycle::{ + on_compose, + on_shutdown, + on_start, +} + +///| +pub impl @port.Lifecycle for NoopLifecycle with fn on_shutdown(_self) -> Unit { + () +} + +///| +/// No-op CommandPort: declares no commands, invoke always raises NotFound. +/// Useful as a default or for testing. +pub(all) struct NoopCommandPort {} + +///| +pub impl @port.CommandPort for NoopCommandPort with fn commands(_self) -> Array[ + @port.CommandDef, +] { + [] +} + +///| +pub impl @port.CommandPort for NoopCommandPort with fn invoke( + _self, + id : String, + _args : Json, +) -> @port.CommandOutcome raise @error.CommandError { + raise NotFound(id) +} + +///| +/// Tokenize a slash argument string, honoring double-quoted spans. +fn tokenize_args(text : String) -> Array[String] { + let tokens : Array[String] = [] + let mut current = StringBuilder() + let mut in_quote = false + for ch in text { + if ch == '"' { + in_quote = !in_quote + } else if ch == ' ' && !in_quote { + if current.to_string() != "" { + tokens.push(current.to_string()) + current = StringBuilder() + } + } else { + current.write_char(ch) + } + } + let last = current.to_string() + if last != "" { + tokens.push(last) + } + tokens +} + +///| +/// Coerce a string token to Json matching the param's ptype. +fn coerce_param(p : @port.CommandParam, raw : String) -> Result[Json, String] { + match p.ptype { + Str => Ok(Json::string(raw)) + Int => { + let n = @string.parse_int(raw) catch { + _ => return Err("parameter " + p.name + " expects Int, got: " + raw) + } + Ok(Json::number(n.to_double())) + } + Bool => + match raw { + "true" | "on" | "yes" => Ok(Json::boolean(true)) + "false" | "off" | "no" => Ok(Json::boolean(false)) + _ => Err("parameter " + p.name + " expects Bool, got: " + raw) + } + Strs => + Ok( + Json::array( + raw.split(",").map(fn(s) { Json::string(s.to_owned()) }).collect(), + ), + ) + } +} + +///| +/// Split a `key=value` token into (key, value). Returns None if no `=`. +fn split_kv(token : String) -> (String, String)? { + let bytes = token.to_array() + let mut i = 0 + while i < bytes.length() { + if bytes[i] == '=' { + let key = token[0:i].to_owned() + let val = token[i + 1:].to_owned() + return Some((key, val)) + } + i += 1 + } + None +} + +///| +/// Find a CommandParam by name in an array. Returns None if not found. +fn find_param( + params : Array[@port.CommandParam], + name : String, +) -> @port.CommandParam? { + for p in params { + if p.name == name { + return Some(p) + } + } + None +} + +///| +/// 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). +pub fn parse_slash_args( + text : String, + params : Array[@port.CommandParam], +) -> Result[Json, String] { + let tokens = tokenize_args(text) + let positional_params = params.filter(fn(p) { p.positional }) + let map : Map[String, Json] = Map::from_array([]) + let mut positional_idx = 0 + // First pass: positional params consume tokens in order, key=value fills others + for token in tokens { + match split_kv(token) { + Some((key, val)) => + match find_param(params, key) { + Some(p) => + match coerce_param(p, val) { + Ok(v) => map[key] = v + Err(e) => return Err(e) + } + None => return Err("unknown parameter: " + key) + } + None => + if positional_idx < positional_params.length() { + let p = positional_params[positional_idx] + match coerce_param(p, token) { + Ok(v) => map[p.name] = v + Err(e) => return Err(e) + } + positional_idx = positional_idx + 1 + } + } + } + // Second pass: apply defaults + check required + for p in params { + if !map.contains(p.name) { + match p.default { + Some(d) => map[p.name] = d + None => + if p.required { + return Err("missing required parameter: " + p.name) + } + } + } + } + Ok(Json::object(map)) +} + +///| +/// 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). +pub fn validate_args( + def : @port.CommandDef, + args : Json, +) -> Result[Json, String] { + let incoming : Map[String, Json] = match args { + Object(m) => m + _ => Map::from_array([]) + } + let out : Map[String, Json] = Map::from_array([]) + for p in def.params { + if incoming.contains(p.name) { + out[p.name] = incoming[p.name] + } else { + match p.default { + Some(d) => out[p.name] = d + None => + if p.required { + return Err("missing required parameter: " + p.name) + } + } + } + } + Ok(Json::object(out)) +} diff --git a/src/builtin/builtin_command.mbt b/src/builtin/builtin_command.mbt new file mode 100644 index 0000000..67994fd --- /dev/null +++ b/src/builtin/builtin_command.mbt @@ -0,0 +1,329 @@ +///| +/// Built-in slash command port for `/compact` and `/fork-here`. +/// `invoke` enqueues a signal on the ControlMailbox for the pump's safe points. +/// The BuiltinCommandPort holds a reference to the Puppet's ControlMailbox. +/// It is constructed once by `Agent::Agent` from the long-lived mailbox +/// reference, then merged with extension commands when the one PuppetConfig +/// is assembled. Extension commands with the same id +/// (`compact` / `fork-here`) take precedence — the product layer can +/// override the builtins by declaring a command with that id. + +///| +/// Built-in command processor. Holds a borrowed `&Mailbox` so `/compact` +/// and `/fork-here` can enqueue fork/compact signals. The mailbox +/// reference is shared with the Puppet and the Agent: one `ControlMailbox` +/// instance, three views. The Agent owns the concrete composition. +priv struct BuiltinCommandPort { + mailbox : &@puppetry.Mailbox + runtime : Ref[AgentRuntime?] + /// Monotonic counter for command ids. Lives on the port so successive + /// invokes produce distinct ids without needing wall-clock time. + mut seq : Int + mut compact_enabled : Bool + mut fork_enabled : Bool +} + +///| +fn BuiltinCommandPort::BuiltinCommandPort( + mailbox : &@puppetry.Mailbox, +) -> BuiltinCommandPort { + { + mailbox, + runtime: Ref(None), + seq: 0, + compact_enabled: true, + fork_enabled: true, + } +} + +///| +/// Helper: mint a unique command id. Combines a stable prefix with the +/// port's monotonic counter so the same id is never returned twice. +fn BuiltinCommandPort::next_command_id( + self : BuiltinCommandPort, + prefix : String, +) -> @puppetry.CommandId { + self.seq = self.seq + 1 + @puppetry.CommandId::unchecked("\{prefix}_\{self.seq}") +} + +///| +impl @port.CommandPort for BuiltinCommandPort with fn commands( + self : BuiltinCommandPort, +) -> Array[@port.CommandDef] { + let definitions : Array[@port.CommandDef] = [] + if self.compact_enabled { + definitions.push({ + id: "compact", + label: "Compact", + description: "Compact the conversation (the modelport decides the strategy)", + category: "session", + ctype: Action, + params: [], + aliases: [], + shortcut: None, + icon: None, + visible: true, + metadata: None, + }) + } + if self.fork_enabled { + definitions.push({ + id: "fork-here", + label: "Fork Here", + description: "Fork the conversation at the given message index (0-based)", + category: "session", + ctype: Action, + params: [ + { + name: "index", + label: "Message Index", + description: "The 0-based transcript index at which to split the new thread", + ptype: Int, + required: false, + default: None, + choices: None, + positional: true, + }, + ], + aliases: [], + shortcut: None, + icon: None, + visible: true, + metadata: None, + }) + } + definitions +} + +///| +/// Normalized command ids for `invoke`, so the enable guard is written once +/// per builtin and the enqueue skeleton below stays id-agnostic. +priv enum BuiltinCommandKind { + Compact + ForkHere +} + +///| +fn BuiltinCommandPort::resolve_command( + self : BuiltinCommandPort, + id : String, +) -> BuiltinCommandKind raise @error.CommandError { + match id { + "compact" if self.compact_enabled => Compact + "compact" => raise NotFound(id) + "fork-here" if self.fork_enabled => ForkHere + "fork-here" => raise NotFound(id) + other => raise NotFound(other) + } +} + +///| +/// Shared enqueue → CommandOutcome skeleton for `/compact` and `/fork-here`. +fn enqueue_outcome( + name : String, + accepted_feedback : String, + result : @puppetry.EnqueueResult, +) -> @port.CommandOutcome { + match result { + Accepted(_) => + Success(feedback=accepted_feedback, structured=None, ui_hint=Some(Toast)) + RejectedStale(reason~, ..) => Failure(reason=name + " rejected: " + reason) + RejectedQueueFull(depth~, ..) => + Failure(reason="\{name} queue full (depth=\{depth})") + _ => Failure(reason="\{name} already requested") + } +} + +///| +async fn BuiltinCommandPort::invoke_idle_compact( + self : BuiltinCommandPort, + args : Json, +) -> @port.CommandOutcome { + let session_id = match args { + Object(fields) => + match fields.get("session_id") { + Some(String(s)) => s + _ => "" + } + String(s) => s + _ => "" + } + if session_id == "" { + return Failure( + reason="compact requires a \"session_id\" argument when no run is active", + ) + } + match self.runtime.val { + Some(runtime) => { + let attempted : Result[@types.CompactOutcome, @error.AgentError] = Ok( + runtime.compact_session(session_id), + ) catch { + error => Err(error) + } + match attempted { + Err(error) => + // Keep provider status/code/request fields in the command outcome. + Failure( + reason="compact failed: " + + bounded_error_label(error.to_string(), 512), + ) + Ok(outcome) => + Success( + feedback="compact completed (\{outcome.mode.to_string()}, session '\{outcome.final_session_id}', \{outcome.messages_after} messages)", + structured=None, + ui_hint=Some(Toast), + ) + } + } + None => + Failure( + reason="no active run and no agent runtime bound: compact unavailable", + ) + } +} + +///| +impl @port.CommandPort for BuiltinCommandPort with fn invoke( + self : BuiltinCommandPort, + id : String, + args : Json, +) -> @port.CommandOutcome raise @error.CommandError { + match self.resolve_command(id) { + Compact => + match (self.mailbox.active_run_id(), self.mailbox.active_turn_id()) { + (Some(run_id), Some(turn_id)) => { + // Manual compact: trigger=Manual. The modelport can pick a + // thorough strategy (LLM summary, OpenAI compat compact endpoint). + let command_id = self.next_command_id("compact") + let cmd : @puppetry.CompactCommand = { + command_id, + target_run_id: run_id, + target_turn_id: turn_id, + trigger: Manual, + } + enqueue_outcome( + "compact", + "compact queued, will execute at the next safe point", + self.mailbox.enqueue_compact(cmd), + ) + } + _ => { + let attempted : Result[@port.CommandOutcome, @error.AgentError] = Ok( + self.invoke_idle_compact(args), + ) catch { + error => Err(Runtime(InvocationFailed(error.to_string()))) + } + let outcome : @port.CommandOutcome = match attempted { + Ok(value) => value + Err(error) => + Failure( + reason="compact failed: " + + bounded_error_label(error.to_string(), 512), + ) + } + outcome + } + } + ForkHere => + match (self.mailbox.active_run_id(), self.mailbox.active_turn_id()) { + (Some(run_id), Some(turn_id)) => { + // Parse optional `index` from args. Default is the current + // transcript length (fork at the tail). + let index = parse_fork_index(args) + let command_id = self.next_command_id("fork") + let cmd : @puppetry.ForkCommand = { + command_id, + target_run_id: run_id, + target_turn_id: turn_id, + fork_index: index, + } + enqueue_outcome( + "fork", + "fork queued at index \{index}, will execute at the next safe point", + self.mailbox.enqueue_fork(cmd), + ) + } + _ => + Failure( + reason="no active run: " + + id + + " can only be invoked while a turn is in flight", + ) + } + } +} + +///| +#warnings("-unused_value") +extend BuiltinCommandPort with @port.CommandPort::{commands, invoke} + +///| +/// Parse the optional `index` argument for `/fork-here`. Recognises: +/// - `{"index": N}` (object form) +/// - `N` (bare number, used by positional slash parsers) +/// Defaults to `0` when absent or unparseable (the caller can override +/// by passing a positive index). A future iteration can return the +/// current transcript length when omitted, but that requires the +/// transcript reference the BuiltinCommandPort does not hold today. +fn parse_fork_index(args : Json) -> Int { + match args { + Object(map) => + if map.contains("index") { + match map["index"] { + Number(n, ..) => n.to_int() + _ => 0 + } + } else { + 0 + } + Number(n, ..) => n.to_int() + _ => 0 + } +} + +///| +/// Build the agent's full command list: the builtins +/// (`/compact`, `/fork-here`) registered first, followed by the extension +/// commands. Extensions that declare a command with id `compact` or +/// `fork-here` are dropped from the list — they override the builtins by +/// being the only one with that id (D5.4). This keeps the BuiltinCommandPort +/// as a sensible default that products can replace. +/// +/// D3-Q1: the builtin port is a per-Agent singleton (constructed once in +/// `Agent::Agent`), so its `seq` counter stays monotonic across turns. +/// This method merely borrows it into the per-turn command list — it does +/// NOT reconstruct the port. +fn compose_agent_commands( + commands : Array[&@port.CommandPort], + builtin_command_port : BuiltinCommandPort, +) -> Array[&@port.CommandPort] { + let builtin : &@port.CommandPort = builtin_command_port as &@port.CommandPort + let extension_ids : Map[String, Unit] = Map::from_array([]) + for cmd_port in commands { + for def in cmd_port.commands() { + extension_ids[def.id] = () + } + } + // If any extension declares `compact` or `fork-here`, the builtin is + // skipped — the extension version wins. Otherwise the builtin is the + // default. + builtin_command_port.compact_enabled = !extension_ids.contains("compact") + builtin_command_port.fork_enabled = !extension_ids.contains("fork-here") + let result : Array[&@port.CommandPort] = [] + if builtin_command_port.compact_enabled || builtin_command_port.fork_enabled { + result.push(builtin) + } + for cmd in commands { + result.push(cmd) + } + result +} + +///| +pub fn Agent::commands(self : Agent) -> Array[&@port.CommandPort] { + compose_agent_commands( + self.runtime.commands, + self.runtime.builtin_command_port, + ) +} diff --git a/src/builtin/builtin_hooks.mbt b/src/builtin/builtin_hooks.mbt new file mode 100644 index 0000000..350b655 --- /dev/null +++ b/src/builtin/builtin_hooks.mbt @@ -0,0 +1,302 @@ +///| +/// Built-in adapters: Posoco-provided implementations of port traits. +/// +/// These structs are concrete adapters, not extension contracts — they live +/// in the root package (not `src/port/`) so the port package contains only +/// the intentional `pub(open)` traits community extensions implement. +/// Extension authors may register their own hook implementations instead of +/// or alongside these. + +// SystemPromptHook — PipelineHook impl that assembles + injects the prompt + +///| +/// Combines a fixed base prompt with contributor sections and prepends the +/// result as a single System message at index 0. Empty sections are skipped. +/// +/// The assembled prompt is cached lazily on the first call to `before_model`. +/// Laziness matters because extension `Lifecycle::on_start` callbacks run +/// inside the first `run_single_turn` but before the first `before_model`, so +/// contributors whose state is initialized in `on_start` must be read after +/// that point. After caching, the text is frozen for the lifetime of the hook. +pub(all) struct SystemPromptHook { + base_prompt : String + contributors : Array[SystemPromptSection] + mut assembled : String? +} + +///| +/// A contributor entry: manifest id (for section header) and the contributor. +pub(all) struct SystemPromptSection { + id : String + contributor : &@port.SystemPromptContributor +} + +///| +pub fn SystemPromptSection::SystemPromptSection( + id~ : String, + contributor~ : &@port.SystemPromptContributor, +) -> SystemPromptSection { + { id, contributor, } +} + +///| +pub fn SystemPromptHook::SystemPromptHook( + base_prompt~ : String, + contributors~ : Array[SystemPromptSection], +) -> SystemPromptHook { + { base_prompt, contributors, assembled: None, } +} + +///| +/// Assemble base + non-empty contributor sections with `id:` headers. +/// Sections are separated by a blank line (`\n\n`). The result is empty when +/// the base prompt and every contributor return empty strings. +pub fn SystemPromptHook::assemble(self : SystemPromptHook) -> String { + let sections : Array[String] = [] + if self.base_prompt != "" { + sections.push(self.base_prompt) + } + for s in self.contributors { + let text = s.contributor.system_prompt() + if text != "" { + sections.push("\{s.id}:\n\{text}") + } + } + sections.join("\n\n") +} + +///| +pub extend SystemPromptHook with @port.PipelineHook::{ + on_post_event, + on_turn_begin, + on_post_event_at, + before_tool, + on_turn_end, +} + +///| +/// Inject one stable SystemMessage at index 0. +/// +/// Semantics: +/// 1. If the assembled prompt is empty, return `messages` unchanged. +/// 2. If `messages` is empty, return `[SystemMessage(assembled)]`. +/// 3. If `messages[0]` is a SystemMessage with the exact same text, return +/// the same array instance (no rewrite, no journal noise). +/// 4. If `messages[0]` is a SystemMessage with different text, replace it. +/// 5. Otherwise prepend the new SystemMessage. +pub impl @port.PipelineHook for SystemPromptHook with fn before_model( + self : SystemPromptHook, + messages : Array[@kernel.Message], +) -> Array[@kernel.Message] raise @port.HookAbort { + let assembled = match self.assembled { + Some(text) => text + None => { + let text = self.assemble() + self.assembled = Some(text) + text + } + } + if assembled == "" { + return messages + } + let sys_msg : @kernel.Message = SystemMessage(content=[Text(assembled)]) + if messages.length() == 0 { + return [sys_msg] + } + match messages[0] { + SystemMessage(content~) => { + match content { + [Text(existing), ..] if existing == assembled => return messages + _ => () + } + let new_messages : Array[@kernel.Message] = [sys_msg] + new_messages.append(messages[1:]) + new_messages + } + _ => { + let new_messages : Array[@kernel.Message] = [sys_msg] + new_messages.append(messages) + new_messages + } + } +} + +// UiRenderHook + +///| +/// 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. +pub(all) struct UiRenderHook { + ui : &@port.UiPort +} + +///| +pub fn UiRenderHook::UiRenderHook(ui~ : &@port.UiPort) -> UiRenderHook { + { ui, } +} + +///| +/// Render a tool-completed stage to the Notice slot. +fn UiRenderHook::render_tool_completed( + self : UiRenderHook, + call : @kernel.ToolCall, + outcome : @kernel.ToolOutcome, +) -> Unit { + let label = call.name.to_string() + let outcome_tag = outcome.to_string() + let intent : @port.UiRender = { + slot: Notice, + key: "tool_\{call.call_id.to_string()}", + title: Some(label), + body: Text(outcome_tag), + ttl_ms: None, + } + self.ui.render(intent) +} + +///| +/// Render a failure stage to the Status slot. +fn UiRenderHook::render_failure( + self : UiRenderHook, + key : String, + reason : String, +) -> Unit { + let intent : @port.UiRender = { + slot: Status, + key, + title: None, + body: Text(reason), + ttl_ms: None, + } + self.ui.render(intent) +} + +///| +pub extend UiRenderHook with @port.PipelineHook::{ + on_turn_begin, + on_post_event_at, + before_model, + before_tool, + on_turn_end, +} + +///| +pub impl @port.PipelineHook for UiRenderHook with fn on_post_event( + self : UiRenderHook, + stage : @port.HookStage, +) -> Unit { + match stage { + // ModelCompleted is intentionally NOT rendered: it is an intermediate + // state in every tool→model round-trip, and a persistent "model + // responded" status misleads the user into thinking the turn stalled + // (QA issue: status shows model response while tools feed back to the + // LLM). Hosts own progress/status; this hook surfaces tool outcomes and + // typed failures only. + ModelCompleted(..) => () + ToolCompleted(call~, outcome~) => self.render_tool_completed(call, outcome) + ToolFailed(call~, reason~) => + self.render_failure( + "tool_fail_\{call.call_id.to_string()}", + "tool \{call.name.to_string()} failed: \{reason}", + ) + ModelFailed(failure~) => + self.render_failure("model_fail", "model failed: \{failure.to_string()}") + } +} + +// NoopUiPort + +///| +/// Default UiPort for headless hosts. `request` raises `Unsupported`. +pub(all) struct NoopUiPort {} + +///| +pub fn NoopUiPort::NoopUiPort() -> NoopUiPort { + NoopUiPort::{ } +} + +///| +pub impl @port.UiPort for NoopUiPort with fn ui_descriptor(_self) -> @port.UiDescriptor { + @port.UiDescriptor::empty() +} + +///| +pub impl @port.UiPort for NoopUiPort with fn render(_self, _intent) -> Unit { + +} + +///| +pub impl @port.UiPort for NoopUiPort with fn request(_self, _req) -> @port.UiResponse { + raise Unsupported(detail="NoopUiPort has no UI backend") +} + +///| +pub extend NoopUiPort with @port.UiPort::{ui_descriptor, render, request} + +// CompositeUiPort + +///| +/// Fan-out UiPort: `render` delivers to every contributor; `request` tries +/// each in order, first non-`Unsupported` wins. +pub(all) struct CompositeUiPort { + contributors : Array[&@port.UiPort] +} + +///| +pub fn CompositeUiPort::CompositeUiPort( + contributors : Array[&@port.UiPort], +) -> CompositeUiPort { + { contributors, } +} + +///| +pub fn CompositeUiPort::contributors( + self : CompositeUiPort, +) -> Array[&@port.UiPort] { + self.contributors.copy() +} + +///| +/// Concatenate autocomplete sources in registration order. +pub impl @port.UiPort for CompositeUiPort with fn ui_descriptor(self) -> @port.UiDescriptor { + let sources : Array[@port.AutocompleteSource] = [] + for c in self.contributors { + let d = c.ui_descriptor() + for s in d.autocomplete_sources { + sources.push(s) + } + } + { autocomplete_sources: sources, } +} + +///| +/// Broadcast to every contributor in registration order. Best-effort. +pub impl @port.UiPort for CompositeUiPort with fn render(self, intent) -> Unit { + for c in self.contributors { + c.render(intent) + } +} + +///| +/// Try each contributor in order. First non-`Unsupported` result wins; +/// all-`Unsupported` raises `Unsupported`. +pub impl @port.UiPort for CompositeUiPort with fn request(self, req) -> @port.UiResponse { + let mut last_unsupported_detail = "no contributors" + for c in self.contributors { + let result : Result[@port.UiResponse, @error.UiError] = Ok(c.request(req)) catch { + e => Err(e) + } + match result { + Ok(resp) => return resp + Err(Unsupported(detail~)) => last_unsupported_detail = detail + Err(other) => raise other + } + } + raise Unsupported( + detail="no contributor supported the request; last detail=\{last_unsupported_detail}", + ) +} + +///| +pub extend CompositeUiPort with @port.UiPort::{ui_descriptor, render, request} diff --git a/src/builtin/builtin_memory.mbt b/src/builtin/builtin_memory.mbt new file mode 100644 index 0000000..552dbcb --- /dev/null +++ b/src/builtin/builtin_memory.mbt @@ -0,0 +1,365 @@ +///| +/// Built-in memory surface: the fixed lead line for the session-opening +/// memory message, the inbound collector behind the Agent's injection step, +/// and the `memory_search` / `memory_add` tool provider built over the +/// aggregated `MemoryPort` sources. Like the other built-in adapters, these +/// live in the root package (not `src/port/`) — concrete adapters, not +/// extension contracts. + +///| +/// Fixed lead line core puts in front of the injected memory message — the +/// one piece of memory-specific text core ever produces. Everything past +/// this line is provider content, untouched by core. +pub const MEMORY_INBOUND_LEAD : String = + #| ## Memory + #| + #| The following is memory recalled for this session, for your information. It may be stale or incomplete — verify against the current conversation before relying on it. + +///| +/// One provider's inbound read with an optional per-call timeout. Any +/// failure — provider raise or timeout — is reported via `on_failure` +/// (cancellation is not a defect and is not reported) and contributes +/// nothing (`None`). +async fn memory_inbound_one( + source : String, + port : &@port.MemoryPort, + session_id : String, + request : String, + timeout_ms : Int?, + on_failure : (String) -> Unit, +) -> String? { + let call = async fn() -> String? { + match timeout_ms { + Some(n) => + @async.with_timeout(n, () => port.inbound(session_id~, request~)) + None => port.inbound(session_id~, request~) + } + } + call() catch { + @async.TimeoutError => { + match timeout_ms { + Some(n) => on_failure("\{source}: timeout after \{n}ms") + None => () + } + None + } + err => { + on_failure("\{source}: \{err.to_string()}") + None + } + } +} + +///| +/// Fetch inbound memory bodies for every provider concurrently. Failures and +/// timeouts are reported via `on_failure` and swallowed — the session +/// continues without that provider's content. `noraise`: every +/// `MemoryPort::inbound` raise is caught locally, so the injection step it +/// feeds never widens its raise set. Results stay in provider-registration +/// order because `@async.all` returns values in submission order. +async fn collect_memory_inbound( + sources : Array[(String, &@port.MemoryPort)], + session_id : String, + request : String, + timeout_ms : Int?, + on_failure : (String) -> Unit, +) -> Array[String] noraise { + let attempted : Result[Array[String?], Error] = Ok( + @async.all( + sources.map(fn(pair) { + let (source, port) = pair + () => { + memory_inbound_one( + source, port, session_id, request, timeout_ms, on_failure, + ) + } + }), + ), + ) catch { + error => Err(error) + } + let out : Array[String] = [] + match attempted { + Ok(results) => + for result in results { + match result { + Some(body) if body.trim() != "" => out.push(body) + _ => () + } + } + Err(_) => () // cancellation: turn is aborting, skip injection + } + out +} + +///| +/// Assemble the single frozen user message: core's lead line followed by the +/// provider bodies in registration order. Placed before the real first user +/// input; never rewritten, never re-read. +fn memory_inbound_message(bodies : Array[String]) -> @kernel.Message { + UserMessage(content=[Text(MEMORY_INBOUND_LEAD + "\n\n" + bodies.join("\n\n"))]) +} + +///| +/// JSON-schema fragment for one tool argument: `{"type": }`. +fn memory_arg_schema(ty : String) -> Json { + Json::object(Map::from_array([("type", Json::string(ty))])) +} + +///| +fn memory_search_schema() -> Json { + Json::object( + Map::from_array([ + ("type", Json::string("object")), + ( + "properties", + Json::object( + Map::from_array([ + ("query", memory_arg_schema("string")), + ("top_k", memory_arg_schema("integer")), + ]), + ), + ), + ("required", Json::array([Json::string("query")])), + ]), + ) +} + +///| +fn memory_add_schema() -> Json { + Json::object( + Map::from_array([ + ("type", Json::string("object")), + ( + "properties", + Json::object( + Map::from_array([ + ("content", memory_arg_schema("string")), + ("source", memory_arg_schema("string")), + ]), + ), + ), + ("required", Json::array([Json::string("content")])), + ]), + ) +} + +///| +/// Read a required string argument from a tool call's JSON object args. +/// `Err` carries the model-facing diagnostic. +fn memory_required_string_arg( + args : Map[String, Json], + tool : String, + key : String, +) -> Result[String, String] { + match args.get(key) { + Some(String(s)) => Ok(s) + Some(_) => Err("\{tool}: argument '\{key}' must be a string") + None => Err("\{tool}: missing required argument '\{key}'") + } +} + +///| +/// Built-in ToolProvider over the aggregated memory sources: exposes +/// `memory_search` / `memory_add` to the model and fans each call out to +/// every matching provider. Provider failures are reported via `on_failure` +/// (the Agent wires it to the `secondary_failure` observer event) and never +/// abort the calling turn. +priv struct MemoryToolProvider { + sources : Array[(String, &@port.MemoryPort)] + on_failure : (String) -> Unit +} + +///| +fn MemoryToolProvider::MemoryToolProvider( + sources~ : Array[(String, &@port.MemoryPort)], + on_failure~ : (String) -> Unit, +) -> MemoryToolProvider { + { sources, on_failure, } +} + +///| +impl @port.ToolProvider for MemoryToolProvider with fn list_tools( + self : MemoryToolProvider, +) -> Array[@kernel.ToolDef] { + let valid_sources : Array[String] = [] + for pair in self.sources { + let (id, _) = pair + valid_sources.push(id) + } + [ + ToolDef( + name=@kernel.ToolName::unchecked("memory_search"), + description="Search accumulated memory (past work, settled decisions, SOPs) before answering questions it may have settled, and before saving anything new.", + input_schema=memory_search_schema(), + owner=@kernel.OwnerId::unchecked("placeholder"), + policy=Sequential, + provenance=Some("posoco.core"), + ), + ToolDef( + name=@kernel.ToolName::unchecked("memory_add"), + description="Save a durable memory for future sessions. Omit source to save to every connected memory provider; valid sources: \{valid_sources.join(", ")}", + input_schema=memory_add_schema(), + owner=@kernel.OwnerId::unchecked("placeholder"), + policy=Sequential, + provenance=Some("posoco.core"), + ), + ] +} + +///| +async fn MemoryToolProvider::execute_memory_search( + self : MemoryToolProvider, + args : Map[String, Json], +) -> @kernel.ToolOutcome noraise { + let query = match memory_required_string_arg(args, "memory_search", "query") { + Ok(q) => q + Err(msg) => return ToolReportedError(content=msg, structured=None) + } + let top_k : Int? = match args.get("top_k") { + Some(Number(n, ..)) => Some(n.to_int()) + Some(_) => + return ToolReportedError( + content="memory_search: argument 'top_k' must be an integer", + structured=None, + ) + None => None + } + let searched : Array[(String, String?)] = @async.all( + self.sources.map(fn(pair) { + let (source, port) = pair + () => { + let text : String? = port.search(query~, top_k?) catch { + err => { + (self.on_failure)("memory_search \{source}: \{err.to_string()}") + None + } + } + (source, text) + } + }), + ) catch { + // Cancellation bypasses catch as a runtime signal; this guard only + // absorbs unexpected ordinary propagation. Contribute nothing either way. + _ => [] + } + let hits : Array[String] = [] + for pair in searched { + let (_, text) = pair + match text { + Some(body) if body.trim() != "" => hits.push(body) + _ => () + } + } + if hits.is_empty() { + Success(content="No matching memory.", structured=None) + } else { + Success(content=hits.join("\n\n"), structured=None) + } +} + +///| +async fn MemoryToolProvider::execute_memory_add( + self : MemoryToolProvider, + args : Map[String, Json], +) -> @kernel.ToolOutcome noraise { + let content = match + memory_required_string_arg(args, "memory_add", "content") { + Ok(c) => c + Err(msg) => return ToolReportedError(content=msg, structured=None) + } + if content.trim() == "" { + return ToolReportedError( + content="memory_add: argument 'content' must be non-empty", + structured=None, + ) + } + let source = match args.get("source") { + Some(String(s)) => Some(s) + Some(_) => + return ToolReportedError( + content="memory_add: argument 'source' must be a string", + structured=None, + ) + None => None + } + let targets : Array[(String, &@port.MemoryPort)] = match source { + Some(id) => { + let matched : Array[(String, &@port.MemoryPort)] = [] + for pair in self.sources { + let (mid, port) = pair + if mid == id { + matched.push((mid, port)) + } + } + if matched.is_empty() { + let valid : Array[String] = [] + for pair in self.sources { + let (mid, _) = pair + valid.push(mid) + } + return ToolReportedError( + content="memory_add: unknown source '\{id}'; valid sources: \{valid.join(", ")}", + structured=None, + ) + } + matched + } + None => self.sources + } + // Concurrent writes; receipt lines stay in registration order because + // `@async.all` returns values in submission order. + let receipts : Array[(String, Result[String, String])] = @async.all( + targets.map(fn(pair) { + let (source, port) = pair + () => { + let outcome : Result[String, String] = Ok( + port.store(content~, metadata=Map::from_array([])), + ) catch { + err => Err(err.to_string()) + } + (source, outcome) + } + }), + ) catch { + _ => [] // cancellation: the calling turn is aborting + } + let lines : Array[String] = [] + let mut failures = 0 + for pair in receipts { + let (source, outcome) = pair + match outcome { + Ok(ticket) => lines.push("\{source}: \{ticket}") + Err(reason) => { + failures = failures + 1 + lines.push("\{source}: failed (\{reason})") + } + } + } + if failures == targets.length() { + ToolReportedError(content=lines.join("\n"), structured=None) + } else { + Success(content=lines.join("\n"), structured=None) + } +} + +///| +impl @port.ToolProvider for MemoryToolProvider with fn execute( + self : MemoryToolProvider, + name : String, + call : @kernel.ToolCall, +) -> @kernel.ToolOutcome raise @error.RuntimeError { + let args : Map[String, Json] = match call.arguments { + Object(fields) => fields + _ => + return ToolReportedError( + content="\{name}: arguments must be a JSON object", + structured=None, + ) + } + match name { + "memory_search" => self.execute_memory_search(args) + "memory_add" => self.execute_memory_add(args) + _ => raise UnknownTool("No executor registered for tool '\{name}'") + } +} diff --git a/src/builtin/moon.pkg b/src/builtin/moon.pkg new file mode 100644 index 0000000..7255665 --- /dev/null +++ b/src/builtin/moon.pkg @@ -0,0 +1 @@ +package "colmugx/posoco/builtin" diff --git a/src/extension/extends.mbt b/src/extension/extends.mbt new file mode 100644 index 0000000..cc90be0 --- /dev/null +++ b/src/extension/extends.mbt @@ -0,0 +1,29 @@ +// Blessed dot-call promotions (MoonBit v0.10.9 deprecation of implicit +// method promotion). Each declaration below re-enables `value.method()` +// syntax for one trait impl owned by this package; without it, callers must +// use the qualified `Trait::method(value)` form. Keep this list limited to +// methods that are actually dot-called within this module. + +///| +pub extend NoopCommandPort with @port.CommandPort::{commands, invoke} + +///| +pub extend RecordingHook with @port.PipelineHook::{before_model, before_tool} + +///| +pub extend RecordingObserver with @port.Observer::{on_event} + +///| +pub extend RecordingSessionStore with @port.SessionStore::{load, save} + +///| +pub extend ScriptedMemoryPort with @port.MemoryPort::{inbound} + +///| +pub extend RecordingToolProvider with @port.ToolProvider::{list_tools} + +///| +pub extend SystemPromptHook with @port.PipelineHook::{before_model} + +///| +pub extend UiRenderHook with @port.PipelineHook::{on_post_event} diff --git a/src/extension/moon.pkg b/src/extension/moon.pkg new file mode 100644 index 0000000..d943ea1 --- /dev/null +++ b/src/extension/moon.pkg @@ -0,0 +1 @@ +package "colmugx/posoco/extension" diff --git a/src/manifest/manifest_aggregate.mbt b/src/manifest/manifest_aggregate.mbt new file mode 100644 index 0000000..f857c81 --- /dev/null +++ b/src/manifest/manifest_aggregate.mbt @@ -0,0 +1,337 @@ +///| +/// Manifest aggregation — merges `Array[&Extension]` into a single +/// `AggregatedPorts` value for the agent constructor. + +///| +/// A registered Lifecycle contributor paired with its declared capability +/// requirements. The pairing survives aggregation so the composition can +/// gate each contributor's `CompositionView` on its own declared `requires` +/// at `Lifecycle::on_compose` delivery time. The manifest id also binds +/// Agent-owned capabilities to their declaring extension. +priv struct LifecycleEntry { + id : String + requires : Array[@port.Capability] + port : &@port.Lifecycle +} + +///| +/// Flattened port bundle ready for the agent constructor. The `model` field +/// is a single value (multi-model routing is solved in-extension), and `ui` +/// is wrapped into one UiPort reference (the agent never sees a bare array). +priv struct AggregatedPorts { + model : &@port.ModelPort + decision : &@port.DecisionPort? + log : &@port.LogPort? + tools : Array[&@port.ToolProvider] + sessions : Array[&@port.SessionStore] + observers : Array[&@port.Observer] + hooks : Array[&@port.PipelineHook] + /// Memory sources paired with their manifest id — the id routes + /// `memory_add`'s optional `source` argument. + memory : Array[(String, &@port.MemoryPort)] + lifecycle : Array[LifecycleEntry] + commands : Array[&@port.CommandPort] + ui : &@port.UiPort + /// Contributor paired with its manifest id (used as the prompt section + /// header so the assembled prompt names its source). + prompt_contributors : Array[(String, &@port.SystemPromptContributor)] +} + +///| +/// Shared tool-name collision scan for the two composition passes that walk +/// provider tool lists (`aggregate_extensions` and `build_tool_routing`). +/// Each pass labels a ToolDef with its own source wording but delegates +/// duplicate detection and the message skeleton here. +priv struct ToolNameIndex { + /// Tool name → source labels in declaration order; `labels[0]` is the + /// first declaration. + labels : Map[String, Array[String]] +} + +///| +fn ToolNameIndex::new() -> ToolNameIndex { + { labels: Map::from_array([]), } +} + +///| +/// Record one declared tool. Returns None for a new name; on the first +/// duplicate returns the labels recorded so far, untouched — callers raise +/// from this arm, so no further bookkeeping happens. +fn ToolNameIndex::record( + self : ToolNameIndex, + name : String, + source_label : String, +) -> Array[String]? { + if self.labels.contains(name) { + Some(self.labels[name]) + } else { + self.labels[name] = [source_label] + None + } +} + +///| +/// The shared collision message skeleton; each pass supplies its own +/// source labels. +fn tool_collision_message(first : String, conflicting : String) -> String { + "tool collision; first declaration: \{first}; conflicting declaration: \{conflicting}" +} + +///| +/// A ToolDef's provenance when present and non-empty; None marks an +/// unlabeled definition and each pass words its own actionable fallback. +fn nonempty_provenance(tool : @kernel.ToolDef) -> String? { + match tool.provenance { + Some(s) if s != "" => Some(s) + _ => None + } +} + +///| +/// Aggregate every extension's manifest into one port bundle. +/// Order follows extension array order; collisions fail fast. +fn aggregate_extensions( + exts : Array[&@port.Extension], +) -> AggregatedPorts raise @error.CompositionError { + if exts.length() == 0 { + raise EmptyManifests + } + // Per-port collectors. + let models : Array[&@port.ModelPort] = [] + let model_manifests : Array[String] = [] + let decisions : Array[&@port.DecisionPort] = [] + let decision_manifests : Array[String] = [] + let logs : Array[&@port.LogPort] = [] + let log_manifests : Array[String] = [] + let tools : Array[&@port.ToolProvider] = [] + let tool_index = ToolNameIndex::new() + let sessions : Array[&@port.SessionStore] = [] + let observers : Array[&@port.Observer] = [] + let hooks : Array[&@port.PipelineHook] = [] + let memory : Array[(String, &@port.MemoryPort)] = [] + let lifecycle : Array[LifecycleEntry] = [] + let commands : Array[&@port.CommandPort] = [] + let command_manifests : Map[String, Array[String]] = Map::from_array([]) + let ui : Array[&@port.UiPort] = [] + let prompt_contributors : Array[(String, &@port.SystemPromptContributor)] = [] + for ext in exts { + let manifest = ext.manifest() + let mid = manifest.id + if mid == "" { + raise ManifestSchemaError( + manifest_id="", + detail="extension returned a manifest with empty id", + ) + } + // models: collect for later cardinality check + for m in manifest.models { + models.push(m) + model_manifests.push(mid) + } + // decisions: optional singleton capability; cardinality checked below + for d in manifest.decisions { + decisions.push(d) + decision_manifests.push(mid) + } + // logs: optional singleton capability; cardinality checked below + for log in manifest.logs { + logs.push(log) + log_manifests.push(mid) + } + // tools: collect + collision check by tool name + for provider in manifest.tools { + for tool_def in provider.list_tools() { + let name = tool_def.name.to_string() + let source_label = match nonempty_provenance(tool_def) { + Some(s) => s + None => + "ToolDef.provenance is empty for tool '" + + name + + "' in manifest '" + + mid + + "' (set ToolDef.provenance to a stable provider id to disambiguate)" + } + match tool_index.record(name, source_label) { + Some(labels) => { + let manifests = labels.copy() + manifests.push(mid) + raise ToolCollision( + name, + tool_collision_message(labels[0], source_label), + manifests~, + ) + } + None => () + } + } + tools.push(provider) + } + // commands: collect + collision check by command id + for cmd in manifest.commands { + for def in cmd.commands() { + let cid = def.id + if command_manifests.contains(cid) { + let existing = command_manifests[cid] + existing.push(mid) + raise CommandCollision(cid, manifests=existing) + } else { + command_manifests[cid] = [mid] + } + } + commands.push(cmd) + } + // simple concat ports + for s in manifest.sessions { + sessions.push(s) + } + for o in manifest.observers { + observers.push(o) + } + for h in manifest.hooks { + hooks.push(h) + } + for m in manifest.memory { + memory.push((mid, m)) + } + for l in manifest.lifecycle { + lifecycle.push({ id: mid, requires: manifest.requires.copy(), port: l, }) + } + for u in manifest.ui { + ui.push(u) + } + for p in manifest.prompt_contributors { + prompt_contributors.push((mid, p)) + } + } + // Model cardinality: exactly 1. + match models.length() { + 0 => raise MissingModel + 1 => () + _ => raise MultipleModels(manifests=model_manifests) + } + // Decision cardinality: optional singleton. Multi-provider routing belongs + // inside one DecisionPort meta-extension. + let decision : &@port.DecisionPort? = match decisions.length() { + 0 => None + 1 => Some(decisions[0]) + _ => raise MultipleDecisions(manifests=decision_manifests) + } + // Log cardinality: optional singleton. Multi-sink fan-out belongs inside + // one LogPort meta-extension so write/durability failure semantics are local. + let log : &@port.LogPort? = match logs.length() { + 0 => None + 1 => Some(logs[0]) + _ => raise MultipleLogs(manifests=log_manifests) + } + // UI cardinality: 0 → NoopUiPort (unwrapped: a UI-less composition never + // waits for a user, so no request events), 1 → wrapped passthrough, + // 2+ → wrapped CompositeUiPort. + let ui_ref : &@port.UiPort = match ui.length() { + 0 => (NoopUiPort() : &@port.UiPort) + 1 => + ( + UserRequestObservingUiPort::UserRequestObservingUiPort(ui[0], observers) : + &@port.UiPort) + _ => + ( + UserRequestObservingUiPort::UserRequestObservingUiPort( + CompositeUiPort(ui.copy()), + observers, + ) : &@port.UiPort) + } + { + model: models[0], + decision, + log, + tools, + sessions, + observers, + hooks, + memory, + lifecycle, + commands, + ui: ui_ref, + prompt_contributors, + } +} + +///| +/// Core-owned wrapper around the aggregated `UiPort`. Every interactive user +/// request (permission confirm, plan review, ask_question) that enters the +/// port broadcasts `UserRequestStarted` / `UserRequestFinished` to the +/// observer bus, so presence-style observers can surface "waiting for user" +/// without coupling to any single caller. The wrapper only sees requests that +/// resolve the port from a `CompositionView` (requires `Capability::Ui`); +/// holders of a raw contributor reference bypass it by construction. +priv struct UserRequestObservingUiPort { + inner : &@port.UiPort + observers : Array[&@port.Observer] +} + +///| +fn UserRequestObservingUiPort::UserRequestObservingUiPort( + inner : &@port.UiPort, + observers : Array[&@port.Observer], +) -> UserRequestObservingUiPort { + { inner, observers, } +} + +///| +fn user_request_label(req : @port.UiRequest) -> (String, String) { + match req { + Input(prompt~, ..) => ("input", prompt) + Confirm(prompt~) => ("confirm", prompt) + Select(prompt~, ..) => ("select", prompt) + } +} + +///| +fn UserRequestObservingUiPort::emit_user_request_event( + self : UserRequestObservingUiPort, + event : @types.TurnEvent, +) -> Unit { + let snapshot = snapshot_turn_event(event) + for observer in self.observers { + // Scope is None: the wrapper sits outside any run identity (v1, same + // precedent as secondary-failure diagnostics). + observer.on_event_at(None, snapshot) + } +} + +///| +impl @port.UiPort for UserRequestObservingUiPort with fn ui_descriptor(self) -> @port.UiDescriptor { + self.inner.ui_descriptor() +} + +///| +impl @port.UiPort for UserRequestObservingUiPort with fn render( + self, + intent : @port.UiRender, +) -> Unit { + self.inner.render(intent) +} + +///| +impl @port.UiPort for UserRequestObservingUiPort with fn request( + self, + req : @port.UiRequest, +) -> @port.UiResponse raise @error.UiError { + let (kind, prompt) = user_request_label(req) + self.emit_user_request_event(UserRequestStarted(kind~, prompt~)) + let result : Result[@port.UiResponse, @error.UiError] = Ok( + self.inner.request(req), + ) catch { + e => Err(e) + } + let outcome : String = match result { + Ok(_) => "answered" + Err(Unsupported(..)) => "unsupported" + Err(Cancelled) => "cancelled" + Err(_) => "failed" + } + self.emit_user_request_event(UserRequestFinished(kind~, outcome~)) + match result { + Ok(response) => response + Err(error) => raise error + } +} diff --git a/src/manifest/moon.pkg b/src/manifest/moon.pkg new file mode 100644 index 0000000..00ea171 --- /dev/null +++ b/src/manifest/moon.pkg @@ -0,0 +1 @@ +package "colmugx/posoco/manifest" diff --git a/src/runtime/basic_host_runtime.mbt b/src/runtime/basic_host_runtime.mbt new file mode 100644 index 0000000..c93a46c --- /dev/null +++ b/src/runtime/basic_host_runtime.mbt @@ -0,0 +1,76 @@ +///| +/// Host-runtime decorator for the composition seam: core basic tools (the +/// built-in memory surface) execute through their own providers ahead of the +/// host's runtime chain, so a host that wires `Agent::with_runtime` without +/// knowing the basic names still runs them. Everything else — model calls, +/// cancellation, compact, and every non-basic tool — forwards to the host +/// runtime untouched. + +///| +priv struct BasicFirstHostRuntime { + inner : &@kernel_exec.HostRuntime + basic : Map[String, &@port.ToolProvider] +} + +///| +impl @kernel_exec.HostRuntime for BasicFirstHostRuntime with fn call_model( + self, + scope : @kernel.InvocationScope, + messages : ArrayView[@kernel.Message], + tool_definitions : Array[@kernel.ToolDef], + call_options : Json, + on_chunk : @kernel_exec.HostChunkCallback?, +) -> Result[@kernel.ModelCallResult, @kernel.ModelFailure] { + self.inner.call_model( + scope, messages, tool_definitions, call_options, on_chunk, + ) +} + +///| +impl @kernel_exec.HostRuntime for BasicFirstHostRuntime with fn execute_tool( + self, + effect_id : @kernel.EffectId, + owner_id : @kernel.OwnerId, + call_id : @kernel.CallId, + tool_name : @kernel.ToolName, + arguments : Json, +) -> @kernel.ToolOutcome { + let name = tool_name.to_string() + match self.basic.get(name) { + Some(provider) => { + let call : @kernel.ToolCall = { call_id, name: tool_name, arguments, } + provider.execute(name, call) catch { + error => + RuntimeFailure( + error_category="ToolProviderError", + message=error.to_string(), + ) + } + } + None => + self.inner.execute_tool( + effect_id, owner_id, call_id, tool_name, arguments, + ) + } +} + +///| +impl @kernel_exec.HostRuntime for BasicFirstHostRuntime with fn cancel_effects( + self, + effect_ids : Array[@kernel.EffectId], + reason : @kernel.CancelReason, +) -> Array[(@kernel.EffectId, @kernel.CancelDisposition)] { + self.inner.cancel_effects(effect_ids, reason) +} + +///| +impl @kernel_exec.HostRuntime for BasicFirstHostRuntime with fn compact( + self, + scope : @kernel.InvocationScope, + messages : ArrayView[@kernel.Message], + tools : Array[@kernel.ToolDef], + call_options : Json, + trigger : @kernel.CompactTrigger, +) -> Result[@kernel.CompactResult, @kernel.ModelFailure] { + self.inner.compact(scope, messages, tools, call_options, trigger) +} diff --git a/src/runtime/chunk_dispatch.mbt b/src/runtime/chunk_dispatch.mbt new file mode 100644 index 0000000..4578a06 --- /dev/null +++ b/src/runtime/chunk_dispatch.mbt @@ -0,0 +1,135 @@ +///| +/// Buffered, per-turn dispatcher for `StreamChunk` telemetry. +/// +/// The model's read loop calls `enqueue` synchronously; the event is put into +/// a bounded `@aqueue.Queue` and the callback returns immediately. A drain +/// task spawned at the start of each turn consumes the queue and emits each +/// chunk to observers in FIFO order. When the queue overflows, the oldest +/// undrained chunk is dropped and a running count is kept; a single +/// `StreamChunksDropped(count~)` event is emitted when the queue drains below +/// capacity or at flush so observers can detect loss without one event per +/// dropped chunk. + +///| +let chunk_queue_capacity : Int = 1024 + +///| +/// Per-turn chunk dispatcher. Created by `run_turn_via_puppet` and wired into +/// the Puppet's stream callback through `AgentRuntime::chunk_dispatcher`. +priv struct ChunkDispatcher { + queue : @aqueue.Queue[@types.TurnEvent] + /// Manual depth counter: `@aqueue.Queue` exposes no length accessor and we + /// need to know when the buffer transitions from full to non-full. + mut depth : Int + /// Number of chunks dropped since the last `StreamChunksDropped` emission. + mut dropped : Int + observers : Array[&@port.Observer] +} + +///| +fn ChunkDispatcher::ChunkDispatcher( + observers : Array[&@port.Observer], +) -> ChunkDispatcher { + { + queue: Queue(kind=Blocking(chunk_queue_capacity)), + depth: 0, + dropped: 0, + observers, + } +} + +///| +/// Synchronously enqueue one chunk. Never blocks the producer: if the buffer +/// is full the oldest event is dropped and `dropped` is incremented. +fn ChunkDispatcher::enqueue( + self : ChunkDispatcher, + chunk : @types.StreamChunk, +) -> Unit { + let event : @types.TurnEvent = StreamChunkReceived(chunk~) + let put_ok : Bool = self.queue.try_put(event) catch { + _ => + // Queue closed (turn already terminal) — drop silently. + return + } + if put_ok { + self.depth = self.depth + 1 + return + } + // Buffer full: drop oldest, count the loss, then enqueue the new chunk. + let evicted : @types.TurnEvent? = self.queue.try_get() catch { _ => None } + match evicted { + Some(_) => { + self.dropped = self.dropped + 1 + self.depth = self.depth - 1 + } + None => () + } + let put_ok2 : Bool = self.queue.try_put(event) catch { _ => false } + if put_ok2 { + self.depth = self.depth + 1 + } +} + +///| +/// Emit a single event to every observer with `None` scope, exactly like the +/// previous synchronous callback path. +fn ChunkDispatcher::emit( + self : ChunkDispatcher, + event : @types.TurnEvent, +) -> Unit { + for observer in self.observers { + observer.on_event_at(None, snapshot_turn_event(event)) + } +} + +///| +/// Drain all buffered events and emit a synthesized `StreamChunksDropped` if +/// any chunks were lost since the last emit. Called at committed-event +/// boundaries and at turn termination so no chunk is observed after a terminal +/// event. +fn ChunkDispatcher::flush(self : ChunkDispatcher) -> Unit { + while true { + let event : @types.TurnEvent? = self.queue.try_get() catch { _ => break } + match event { + Some(e) => { + self.depth = self.depth - 1 + self.emit(e) + } + None => break + } + } + if self.dropped > 0 { + let count = self.dropped + self.dropped = 0 + self.emit(StreamChunksDropped(count~)) + } +} + +///| +/// Background drain loop. Runs for the lifetime of one turn. It normally +/// blocks on `get()`; the turn ends by closing the queue, which wakes the +/// loop so `with_task_group` can reap the task. +async fn ChunkDispatcher::drain_loop(self : ChunkDispatcher) -> Unit { + while true { + let event : @types.TurnEvent = self.queue.get() catch { + _ => + // Queue closed: turn is ending. + break + } + self.depth = self.depth - 1 + self.emit(event) + // If the buffer just drained below capacity and we dropped chunks while + // it was full, emit one synthesized drop event now. + if self.depth < chunk_queue_capacity && self.dropped > 0 { + let count = self.dropped + self.dropped = 0 + self.emit(StreamChunksDropped(count~)) + } + } +} + +///| +/// Close the queue. Idempotent; wakes the drain loop so the task terminates. +fn ChunkDispatcher::close(self : ChunkDispatcher) -> Unit { + self.queue.close() +} diff --git a/src/runtime/moon.pkg b/src/runtime/moon.pkg index f285a62..1291461 100644 --- a/src/runtime/moon.pkg +++ b/src/runtime/moon.pkg @@ -1,6 +1 @@ -import { - "colmugx/posoco/kernel", - "colmugx/posoco/port", - "colmugx/posoco/types", - "colmugx/posoco/error", -} +package "colmugx/posoco/runtime" diff --git a/src/runtime/runtime_shim.mbt b/src/runtime/runtime_shim.mbt new file mode 100644 index 0000000..fbf36e1 --- /dev/null +++ b/src/runtime/runtime_shim.mbt @@ -0,0 +1,70 @@ +///| +/// Mechanical adapter from the public `@runtime.Runtime` seam to the +/// internal `HostRuntime` boundary. The shim only packs/unpacks +/// `EffectContext` — it adds no semantics of its own, so the public trait +/// and the internal contract cannot drift apart silently. + +///| +priv struct RuntimeHostShim { + inner : &@runtime.Runtime +} + +///| +fn RuntimeHostShim::RuntimeHostShim( + inner : &@runtime.Runtime, +) -> RuntimeHostShim { + { inner, } +} + +///| +impl @kernel_exec.HostRuntime for RuntimeHostShim with fn call_model( + self, + scope : @kernel.InvocationScope, + messages : ArrayView[@kernel.Message], + tool_definitions : Array[@kernel.ToolDef], + call_options : Json, + on_chunk : @kernel_exec.HostChunkCallback?, +) -> Result[@kernel.ModelCallResult, @kernel.ModelFailure] { + self.inner.call_model( + scope, messages, tool_definitions, call_options, on_chunk, + ) +} + +///| +impl @kernel_exec.HostRuntime for RuntimeHostShim with fn execute_tool( + self, + effect_id : @kernel.EffectId, + owner_id : @kernel.OwnerId, + call_id : @kernel.CallId, + tool_name : @kernel.ToolName, + arguments : Json, +) -> @kernel.ToolOutcome { + self.inner.execute_tool({ + effect_id, + owner: owner_id, + call_id, + tool_name, + arguments, + }) +} + +///| +impl @kernel_exec.HostRuntime for RuntimeHostShim with fn cancel_effects( + self, + effect_ids : Array[@kernel.EffectId], + reason : @kernel.CancelReason, +) -> Array[(@kernel.EffectId, @kernel.CancelDisposition)] { + self.inner.cancel_effects(effect_ids, reason) +} + +///| +impl @kernel_exec.HostRuntime for RuntimeHostShim with fn compact( + self, + scope : @kernel.InvocationScope, + messages : ArrayView[@kernel.Message], + tools : Array[@kernel.ToolDef], + call_options : Json, + trigger : @kernel.CompactTrigger, +) -> Result[@kernel.CompactResult, @kernel.ModelFailure] { + self.inner.compact(scope, messages, tools, call_options, trigger) +}