Events & streams

The HarnessEvent discriminated union the harness emits.

drover normalises pi-agent-core’s events into a single discriminated union. Consumers iterate RunHandle.events: AsyncIterable<HarnessEvent>; plugins observe via onEvent.

The union

kindwhenpayload
run_startfirst thing emittedagentId, specHash
input_validatedinput passed inputSchema
turn_starteach pi turnturn (1-based)
llm_callmodel dispatchmodelName, reasoning?
thinkingmodel emits thinking blocktext
assistant_textmodel emits final text per turntext
tool_call_startpi dispatches a tooltoolUseId, toolName, input
tool_call_endtool returnstoolUseId, toolName, result, durationMs
usageper-call accountingusage: { inputTokens, outputTokens, costUsd? }
compactionhistory compactedbeforeTokens, afterTokens, collapsedRange
subagent_startchild spawnchildRunId, agentId
subagent_endchild completeschildRunId, status
output_validatedoutput passed outputSchema
output_retryoutput failed; retryingattempt, reason
run_endterminalstatus
errortagged errortag, message

Every event has runId, ts. Turn-scoped events also have turn.

Pairing

Tool calls always come in pairs — tool_call_start followed by tool_call_end. Pair by toolUseId. If a run aborts mid-tool, the start is recorded but the end may be missing.

Subagent observability

When the harness invokes the auto-injected task tool to spawn a child, the parent stream gets:

tool_call_start (task)
subagent_start  { childRunId, agentId }
... (child runs; events NOT mirrored)
subagent_end    { childRunId, status }
tool_call_end   (task)

The child’s own events flow through ITS harness instance — accessible via storage (listEvents(childRunId)) or by attaching observers to the child spec’s plugins.

Consuming

ts
const handle = runAgent(spec, input);

for await (const e of handle.events) {
  switch (e.kind) {
    case "tool_call_start":
      console.log(`→ ${e.toolName}`);
      break;
    case "tool_call_end":
      if (e.result.isError) console.log(`  ✗ ${e.result.content}`);
      break;
    case "usage":
      console.log(`  ${e.usage.inputTokens}/${e.usage.outputTokens}`);
      break;
  }
}

The Promise + AsyncIterable both eventually settle. If you never iterate events, the queue grows unbounded (single-run scale, fine for short runs); for high-volume scenarios use the runtime layer instead.

Persistence

When storage is wired, every event lands in the run_events table in seq order. Replay = listEvents(runId) + iterate.

Type to search…

↑↓ navigate open esc close