contenox
Browse docs/

Engine event contract

Status: normative. This document is THE contract between the task engine’s event stream and its consumers — the ACP translators today, the terminal UI’s engine-bridge next, and the checkpoint machinery. The matrix below is asserted in CI:

  • internal/kernel/taskengine/events_contract_test.go drives representative runs and asserts sequence, fields, and addresses;
  • internal/kernel/taskengine/events_journal_test.go (TestUnit_KVJournalTaskEventSink_JournalingMatrix) asserts the journaling column;
  • internal/surfaces/acpsvc/events_test.go (engineEventTranslationMatrix) asserts the ACP translation column.

Every kind the engine can emit is enumerated by taskengine.AllTaskEventKinds(); adding a kind without updating this document and those tests fails CI. When code and this document disagree, fix the drift — never loosen the assertion.

1. The event type

Events are taskengine.TaskEvent, JSON-marshalled on the wire. Evolution policy: additive only — new fields use omitempty/omitzero, existing field names and meanings never change (journal rows and bus subscribers parse old payloads forever).

Fields present on (almost) every event:

FieldMeaning
kindone of the kinds below
timestampUTC emission time
request_idrun correlation ID, when the run context carries one
scopehierarchical address, see §2
chain_id, task_id, task_handlerflat mirrors of the address (legacy wire shape; stay populated identically)
retrytask attempt index, 0-based; 0 for chain-level events

2. Hierarchical addresses (scope)

EventScope{chain, task, tool_call} is the structured address of an event — the same address CapturedStateUnit.Scope carries and the address checkpoints name resume positions with. Population invariants:

  • scope.chain is set on every event emitted during an ExecEnv run (the executor installs a chain-level scope on the run context before the first event).
  • scope.task is set on every event emitted inside a task attempt: step_started, step_chunk, step_stream_end, step_completed, step_failed, token_usage, tool_call_pending, tool_call, and any approval/HITL event raised while a tool executes. Chain-level events (chain_started, chain_completed, chain_failed) and print carry no task address.
  • scope.tool_call is set exactly on events attributable to ONE tool invocation: tool_call_pending/tool_call (all paths, including the error-result path for unresolvable calls), and approval/HITL events emitted from within a call context. The value is the engine-minted call ID (ensureUniqueToolCallID) — unique per invocation even when a model reuses IDs across turns. For tools-handler tasks it is a synthetic per-invocation ID derived from the task ID.

What checkpointing can rely on: (scope.chain, scope.task, retry) uniquely names a task attempt within a run; (scope.chain, scope.task, scope.tool_call) uniquely names one tool invocation; tool_call.approval_id equals scope.tool_call on the execute_tool_calls paths, so a checkpoint keyed by approval ID can be joined back to its emitting position without string parsing. CapturedStateUnit.Scope carries {chain, task} for every recorded step.

3. Per-kind matrix

“Journaled” = persisted by KVJournalTaskEventSink (see §5). “ACP” = number of notifications the acpsvc translators render for a representative event (see §6).

KindFires whenPopulated fields (beyond §1)OrderingJournaledACP
chain_startedExecEnv begins a runscope.chainfirst event of the runyes0
step_starteda task attempt begins (once per retry)scope.{chain,task}, task_handler, retrybefore every other event of that attemptyes1 unless tool-bearing handler
step_chunkone streamed content/thinking delta reaches the sink; or the single coalesced reply on the non-streaming fallbackcontent and/or thinking (≥1 non-empty; empty deltas are never published), model_name, provider_type, backend_idafter the attempt’s step_started, before its step_stream_endno1 per non-empty field, prose handlers only
step_stream_enda model call of the attempt completes successfully — streamed or fallbackchunk_count (number of content/thinking parcels, counted independently of Wants), finish_reason (provider-verbatim; "" on the fallback, which reports none), usage{prompt,completion,total} when the provider reported usage, model_name, provider_type, backend_idafter the LAST step_chunk of the call, before the attempt’s step_completed; never emitted on mid-stream failure (step_failed closes the attempt instead)yes — the durable stream bracket0 (consumed, not rendered)
step_completeda task attempt succeedsoutput_type, transitionlast event of a successful attemptyes1 unless tool-bearing handler
step_faileda task attempt failserror (output_type empty)last event of a failed attempt; a retrying task emits a fresh step_started after ityes1 unless tool-bearing handler
chain_completedthe run ends successfullyoutput_typefinal event of the runyes0
chain_failedthe run ends in errorerrorfinal event of the runyes0
chain_suspendedthe run parks on a pending human approval past the fast window and checkpointsapproval_id (= the checkpoint key = scope.tool_call), scope.{chain,task,tool_call} — the full address of the interrupt pointfinal event of the run segment; published only after the checkpoint is durably persisted, so a consumer reacting to it can already resumeyes0 (the permission flow’s approval card is the suspension UI)
approval_requestedHITL machinery (hitlservice/localtools) needs a human verdict for a tool callapproval_id, tool_name, approval_args, approval_diff, hook_name, hitl_*between the affected call’s tool_call_pending and tool_callyes0 (rendered by the permission flow, not the event translator)
hitl_decisiona HITL policy resolveshitl_action, hitl_reason, hitl_policy_*, hitl_matched_rule, hitl_timeout_safter its approval_requestedyes0
tool_call_pendingexecute_tool_calls is about to run one resolvable model-requested calltool_name, approval_id = scope.tool_call, approval_argsbefore the matching tool_call (same approval_id)yes1
tool_callone tool invocation finished (success, tool-level error, or unresolvable/hidden/disallowed call)tool_name, approval_id (execute_tool_calls paths), content (serialized result), error, tool_diff_{path,old_text,new_text}, approval_argsafter its tool_call_pending when one was emitted (tools-handler tasks and error-result paths emit tool_call without a pending)yes1 (+ a plan update for mission_plan)
printa task’s print template renders after a successful taskcontent; address is chain-level only (no scope.task)after the task’s step_completedyes1
token_usagebefore each chat model call (pre-check), and again after a context shifttoken_used (input-side total incl. tool schemas), token_size (budget; 0 = unlimited), model_nameinside the attempt, before its step_chunks; at most twice per callyes1

4. Ordering guarantees

The engine executes one task at a time; events of one run are totally ordered (per-run bus subject, append-only journal):

  1. Chain bracket: chain_started is the first event; exactly one of chain_completed / chain_failed / chain_suspended is the last. A suspended-and-resumed run journals one bracket pair per segment under the SAME request_id: chain_started … chain_suspended, then (after the verdict) chain_started … chain_completed|chain_failed|chain_suspended. That shared request ID is the documented linkage between the segments — replay of one request shows the whole run including where it slept.
  2. Attempt bracket: every attempt is step_startedstep_completed | step_failed. Attempts of the same task (retries) and of consecutive tasks never interleave. Exception: the attempt interrupted by a suspension emits neither — chain_suspended (carrying that attempt’s task address) closes the segment, and the resumed segment re-runs the task as a fresh attempt. Likewise the gated call’s tool_call_pending is left without its tool_call in the suspended segment; the resumed segment re-emits the pending/call pair when the call finally executes.
  3. Stream bracket: within an attempt, a successful model call emits its step_chunks in delta order followed by exactly one step_stream_end. This holds on the non-streaming fallback too (one coalesced chunk — or zero, for a tool-call-only reply — then the bracket). A call that fails mid-stream emits no bracket; the attempt ends with step_failed. Replay therefore always distinguishes “streamed and finished (how, with what usage)” from “died mid-stream”.
  4. Tool bracket: per invocation, tool_call_pending (when the call is resolvable) precedes its tool_call; approval/HITL events for that call sit between them. Calls of one batch are emitted in the model’s call order.

5. Journaling and replay

KVJournalTaskEventSink persists events per request_id (key taskevents:<request_id>), after forwarding to the wrapped sink:

  • step_chunk is the only unjournaled kind. Its final text is already in chat history; step_stream_end is the durable record that streaming happened and how it ended.
  • Events without a request_id are not journaled.
  • Large text fields (content, thinking, approval_diff, tool_diff_old_text, tool_diff_new_text) are capped at 16 KiB with an explicit …[truncated] marker; the live stream carries full values.
  • The journal is a ring: the newest 500 events per request are kept.
  • GetJournaledEvents replays in arrival order, so brackets (§4) hold on replay minus the chunks.

6. Subscription: sinks, Wants, bus subjects

TaskEventSink.Wants(kind) is a per-kind subscription filter. Its contract:

  • Wants gates ONLY whether events are built and published. It must never select an execution path — execution semantics are identical whether every Wants returns true or false (streaming is observation, not a mode).
  • Corollary: step_stream_end.chunk_count counts the stream’s content/thinking parcels regardless of whether anyone consumed step_chunk.
  • BusTaskEventSink wants every kind while a bus is attached and publishes each event to taskengine.events and, when the event has a request ID, to taskengine.events.request.<request_id> (the subject an engine-bridge should subscribe to for one run). Bus retention is short (minutes); the journal is the durable record.
  • KVJournalTaskEventSink delegates Wants to its wrapped sink (with no inner sink it consumes everything) and journals per §5.
  • bridgeEventSink (internal/surfaces/vscodeagent/events.go) wants only the kinds the VS Code bridge maps to a notification: step_chunk, print, tool_call_pending, tool_call, hitl_decision, step_started/completed/failed, token_usage. It is an in-process sink — no bus, no journal.

7. ACP translation

Two translators render events for ACP clients and are kept case-for-case identical: Transport.publishEvent (internal/surfaces/acpsvc/events.go) and nativeEventTranslator.publish (internal/surfaces/acpsvc/native_turn.go). The “ACP” column in §3 is their contract. Translation rules a bridge author should copy:

  • step_chunk reaches the transcript only for prose handlers (IsAssistantProseHandler); a route task’s streamed output is control flow, not narration.
  • step_started/completed/failed render as a task card only for handlers that are NOT tool-bearing (IsToolBearingHandler) — tool-bearing handlers are represented by their tool_call_pending/tool_call events instead.
  • step_stream_end and the chain/approval/HITL kinds are consumed without a wire notification (ACP has no end-of-stream frame; approvals render via the permission flow). Consumers MUST tolerate — i.e. explicitly ignore — kinds they do not render; they must never fail on them or on new additive fields such as scope.

Esc to close