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.godrives 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:
| Field | Meaning |
|---|---|
kind | one of the kinds below |
timestamp | UTC emission time |
request_id | run correlation ID, when the run context carries one |
scope | hierarchical address, see §2 |
chain_id, task_id, task_handler | flat mirrors of the address (legacy wire shape; stay populated identically) |
retry | task 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.chainis set on every event emitted during anExecEnvrun (the executor installs a chain-level scope on the run context before the first event).scope.taskis 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) andprintcarry no task address.scope.tool_callis 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. Fortools-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).
| Kind | Fires when | Populated fields (beyond §1) | Ordering | Journaled | ACP |
|---|---|---|---|---|---|
chain_started | ExecEnv begins a run | scope.chain | first event of the run | yes | 0 |
step_started | a task attempt begins (once per retry) | scope.{chain,task}, task_handler, retry | before every other event of that attempt | yes | 1 unless tool-bearing handler |
step_chunk | one streamed content/thinking delta reaches the sink; or the single coalesced reply on the non-streaming fallback | content and/or thinking (≥1 non-empty; empty deltas are never published), model_name, provider_type, backend_id | after the attempt’s step_started, before its step_stream_end | no | 1 per non-empty field, prose handlers only |
step_stream_end | a model call of the attempt completes successfully — streamed or fallback | chunk_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_id | after 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 bracket | 0 (consumed, not rendered) |
step_completed | a task attempt succeeds | output_type, transition | last event of a successful attempt | yes | 1 unless tool-bearing handler |
step_failed | a task attempt fails | error (output_type empty) | last event of a failed attempt; a retrying task emits a fresh step_started after it | yes | 1 unless tool-bearing handler |
chain_completed | the run ends successfully | output_type | final event of the run | yes | 0 |
chain_failed | the run ends in error | error | final event of the run | yes | 0 |
chain_suspended | the run parks on a pending human approval past the fast window and checkpoints | approval_id (= the checkpoint key = scope.tool_call), scope.{chain,task,tool_call} — the full address of the interrupt point | final event of the run segment; published only after the checkpoint is durably persisted, so a consumer reacting to it can already resume | yes | 0 (the permission flow’s approval card is the suspension UI) |
approval_requested | HITL machinery (hitlservice/localtools) needs a human verdict for a tool call | approval_id, tool_name, approval_args, approval_diff, hook_name, hitl_* | between the affected call’s tool_call_pending and tool_call | yes | 0 (rendered by the permission flow, not the event translator) |
hitl_decision | a HITL policy resolves | hitl_action, hitl_reason, hitl_policy_*, hitl_matched_rule, hitl_timeout_s | after its approval_requested | yes | 0 |
tool_call_pending | execute_tool_calls is about to run one resolvable model-requested call | tool_name, approval_id = scope.tool_call, approval_args | before the matching tool_call (same approval_id) | yes | 1 |
tool_call | one 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_args | after its tool_call_pending when one was emitted (tools-handler tasks and error-result paths emit tool_call without a pending) | yes | 1 (+ a plan update for mission_plan) |
print | a task’s print template renders after a successful task | content; address is chain-level only (no scope.task) | after the task’s step_completed | yes | 1 |
token_usage | before each chat model call (pre-check), and again after a context shift | token_used (input-side total incl. tool schemas), token_size (budget; 0 = unlimited), model_name | inside the attempt, before its step_chunks; at most twice per call | yes | 1 |
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):
- Chain bracket:
chain_startedis the first event; exactly one ofchain_completed/chain_failed/chain_suspendedis the last. A suspended-and-resumed run journals one bracket pair per segment under the SAMErequest_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. - Attempt bracket: every attempt is
step_started…step_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’stool_call_pendingis left without itstool_callin the suspended segment; the resumed segment re-emits the pending/call pair when the call finally executes. - Stream bracket: within an attempt, a successful model call emits its
step_chunks in delta order followed by exactly onestep_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 withstep_failed. Replay therefore always distinguishes “streamed and finished (how, with what usage)” from “died mid-stream”. - Tool bracket: per invocation,
tool_call_pending(when the call is resolvable) precedes itstool_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_chunkis the only unjournaled kind. Its final text is already in chat history;step_stream_endis the durable record that streaming happened and how it ended.- Events without a
request_idare 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.
GetJournaledEventsreplays 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:
Wantsgates ONLY whether events are built and published. It must never select an execution path — execution semantics are identical whether everyWantsreturns true or false (streaming is observation, not a mode).- Corollary:
step_stream_end.chunk_countcounts the stream’s content/thinking parcels regardless of whether anyone consumedstep_chunk. BusTaskEventSinkwants every kind while a bus is attached and publishes each event totaskengine.eventsand, when the event has a request ID, totaskengine.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.KVJournalTaskEventSinkdelegatesWantsto 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_chunkreaches the transcript only for prose handlers (IsAssistantProseHandler); a route task’s streamed output is control flow, not narration.step_started/completed/failedrender as a task card only for handlers that are NOT tool-bearing (IsToolBearingHandler) — tool-bearing handlers are represented by theirtool_call_pending/tool_callevents instead.step_stream_endand 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 asscope.