From b4ee3aaeea183b793d320d71eb91ee80c6a1c583 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 16 May 2026 01:06:27 +0200 Subject: [PATCH] docs(agent): document harness hook design --- packages/agent/docs/agent-harness.md | 334 ++++++++++--------- packages/agent/docs/hooks.md | 465 +++++++++++++++++++++++++++ 2 files changed, 629 insertions(+), 170 deletions(-) create mode 100644 packages/agent/docs/hooks.md diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index 5a092fa2..18749d71 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -158,14 +158,19 @@ If the system-prompt callback throws while starting `prompt`, `skill`, or `promp ## Hooks and events -Current hooks and listeners receive only the event payload. There is no extension context object yet. +The target hook system is described in [hooks.md](./hooks.md). + +Summary: + +- `AgentHarness` emits typed hook events and consumes typed results. +- A single hooks implementation owns registration, cleanup, provenance, and result reducers. +- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result. +- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events. +- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types. +- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes. Event payloads describe what is happening. Harness getters describe latest config for future snapshots. -The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events. - -A future extension context should expose a harness facade plus a queued-write session facade. The facade must not expose APIs that can deadlock the current event dispatch. In particular, listeners/hooks should not call `waitForIdle()` for the active run; expose a `runWhenIdle(() => Promise)` scheduling API instead. This is future extension-context work; current listeners/hooks receive only payloads and no safe harness facade. - ## Planned session facade Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes. @@ -236,141 +241,58 @@ npm run coverage:harness ## Implementation todo -This list tracks the remaining work before treating `AgentHarness` as migration-ready. +This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom. -### 1. Remove `Agent` dependency from `AgentHarness` +### 1. Add explicit tool registry read/update semantics -Implemented. +Status: In progress -`AgentHarness` now calls `runAgentLoop()` directly instead of owning an internal `Agent` instance. The harness owns active run/abort-controller lifecycle, queue draining, provider stream configuration, event reduction, session persistence, pending write flushing, and save-point snapshot refresh. +Done: -Implemented validation: +- Added `setTools(tools, activeToolNames?)`. +- Added `setActiveTools(toolNames)`. +- Invalid active tool names reject with `AgentHarnessError`. +- Added generic app tool shape via `AgentHarness`. +- Exported `QueueMode` from core types. +- Added `AgentHarnessOptions.steeringMode` and `followUpMode`. +- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. -- prompt construction and public runtime config getters/mutators -- steering queue draining and `queue_update` emission -- follow-up queue draining and `queue_update` emission -- `before_agent_start` message ordering and persistence -- abort clearing steer/follow-up queues while preserving `nextTurn` messages -- thrown hook failure cleanup with persisted assistant error messages and settlement -- save-point refresh for model, thinking level, resources, system prompt, and active tools -- pending listener session write ordering after agent-emitted messages -- external `waitForIdle()` waiting for awaited listeners and run settlement -- `tool_call` and `tool_result` hook behavior through the direct loop -- provider stream wrapper behavior in `agent-harness-stream.test.ts` +Remaining: -Remaining lifecycle hardening beyond this refactor is tracked in the final lifecycle hardening suite. +- Add `getTools()` semantics. +- Add `getActiveTools()` semantics. +- Decide and implement tool update observability events. +- Include active-tool-only updates in the runtime config observability plan. -### 2. Finish curated provider/stream configuration +### 2. Design per-`AgentHarness` model registry -Implemented so far: +Status: Planned -- `AgentHarnessOptions.streamOptions` provides curated request configuration. -- `getStreamOptions()` returns a shallow copy of current harness config. -- `setStreamOptions()` replaces current harness config. -- Stream options are snapshotted in `createTurnState()` and applied with `applyTurnState()`. -- `headers` and `metadata` maps are shallow-copied when stream options are copied. -- `sessionId` is derived from `session.getMetadata().id` in the turn snapshot. -- The harness installs its own internal stream wrapper and calls `streamSimple()`. -- The wrapper ignores raw incoming provider options except lifecycle-owned fields that must come from the low-level loop: `signal` and `reasoning`. -- Credentials and auth headers from `getApiKeyAndHeaders()` are resolved per provider request. +Done: -Implemented provider hook behavior: +- Current `setModel()` behavior is preserved. -- `before_provider_request` runs before `streamSimple()` and can patch curated stream options for the current request only. -- `before_provider_payload` maps to the underlying `pi-ai` `onPayload` and can inspect/replace provider-specific payloads. -- `after_provider_response` maps to the underlying `pi-ai` `onResponse` and observes response status/headers before body consumption. -- `AgentHarnessStreamOptionsPatch` has explicit deletion semantics: - - top-level fields present with `undefined` clear that option. - - `headers` and `metadata` patches may set individual keys to `undefined` to delete them. - - `headers: undefined` or `metadata: undefined`, when explicitly present, clears the whole map. -- Current-request stream option merge order is: - 1. snapshotted `streamOptions` - 2. auth headers from `getApiKeyAndHeaders()` - 3. `before_provider_request` patches, in hook registration order -- `before_provider_request` does not patch `reasoning`; add that only if a concrete use case appears. - -Implemented validation: - -- `packages/agent/test/harness/agent-harness-stream.test.ts` uses the `pi-ai` faux provider. -- Tests cover stream option forwarding, auth header merge, request hook patching, request hook deletion semantics, request hook chaining, payload hook chaining, and busy/save-point snapshot behavior. - -### 3. Design per-`AgentHarness` model registry - -Not started. - -Still needed: +Remaining: - Decide how applications supply the model registry. - Decide whether the harness stores concrete `Model` objects, model references, or both. - Validate model selection against the registry. - Define model change semantics during active turns and save points. -- Preserve current `setModel()` behavior until the registry model is designed. -### 4. Design generic hook/event extension mechanism +### 3. Full `AgentHarness` lifecycle/state pass -Current cleanup already done: +Status: In progress -- Removed `AgentHarnessContext`. -- Hooks receive only event payloads. -- `emitHook(event)` derives the hook type from `event.type`. +Done: -Still needed: - -- Define extension/listener context shape. -- Expose a harness facade plus a session facade rather than raw internals. -- The harness facade should expose safe runtime APIs and `runWhenIdle(() => Promise)`; it should not expose active-run `waitForIdle()` to listeners/hooks. -- The session facade should wrap the internal session and participate in pending session write queue semantics so writes remain ordered with agent-emitted messages. -- Decide which public harness APIs are allowed from each hook/event. -- Decide whether hooks can mutate turn snapshots directly or only through explicit hook results/public APIs. -- Clarify event payload semantics versus harness getter semantics. -- Revisit `AgentHarnessOwnEvent` versus `AgentHarnessEvent`. -- Define hook result chaining where it has clean transform semantics: - - `before_provider_request`: each hook receives the stream options produced by previous hooks. - - `before_provider_payload`: each hook receives the payload produced by previous hooks. - - possibly `context`: each hook receives the messages produced by previous hooks. - - possibly `tool_result`: each hook receives the result fields produced by previous hooks. -- Do not chain hooks where semantics are policy-based or ambiguous until explicitly designed, such as `tool_call`, `session_before_compact`, `session_before_tree`, and `before_agent_start`. - -### 5. Add explicit tool registry read/update semantics - -Implemented so far: - -- `setTools(tools, activeToolNames?)` -- `setActiveTools(toolNames)` -- invalid active tool names reject with `AgentHarnessError` -- generic common app tool shape via `AgentHarness` -- `QueueMode` exported from core types -- `AgentHarnessOptions.steeringMode` / `followUpMode` -- live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()` methods -- queue modes are immediate/live, matching coding-agent behavior - -Still needed: - -- Add `getTools()` semantics. -- Add `getActiveTools()` semantics. -- Decide and implement tool update observability events. -- Include active-tool-only updates in the uniform runtime config observability plan. - -### 6. Full `AgentHarness` lifecycle/state pass - -Implemented so far: - -- Removed constructor `void syncFromTree()`. -- Removed `syncFromTree()`. +- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`. - Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`. -- Low-level `AgentLoopConfig.prepareNextTurn` save-point update exists. -- `prepareNextTurn` updates low-level context/model/thinking-level and harness-applied stream/session snapshot state. -- The loop converts `ThinkingLevel` to provider `reasoning` internally. -- `phase` replaces boolean idle. -- Pending session writes are based on session-entry shapes without generated fields. +- Added explicit `phase` in place of boolean idle state. +- Save points refresh context, model, thinking level, stream options, and session snapshot state. +- Pending session writes use session-entry shapes without generated fields. - Pending session writes flush at save points, settlement, and failure cleanup. -- `steer`, `followUp`, and `nextTurn` accept text plus optional images and create `UserMessage` internally. -- `nextTurn` ordering is fixed: queued messages before the new user message. -- Removed `liveOperationId`. -- Removed `shell()`; use `harness.env`. - -Implemented in the hardening pass: - +- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images. +- `nextTurn` messages are inserted before the new user prompt. - Structural compaction/tree operations restore phase with `finally`. - Public harness failures normalize subsystem causes to `AgentHarnessError`. - Pending session writes flush one-by-one and are not dropped on failure. @@ -380,7 +302,7 @@ Implemented in the hardening pass: - Idle model/thinking/tool updates validate and persist before committing in-memory state. - `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen. -Still needed: +Remaining: - Finalize phase/idle semantics. - Audit whether `settled` can fire too early. @@ -388,72 +310,144 @@ Still needed: - Audit follow-up behavior around `agent_end`. - Implement auto-compaction decision point. - Implement retry handling. -- Verify `before_agent_start` hook semantics against coding-agent: - - current behavior appends returned messages after the user/next-turn prompt messages. - - decide whether replacement, prepend, append, or transform semantics are correct. -- Decide if `before_agent_start` needs more turn info such as tools/tool snippets. -- Document or change timing for model/thinking/stream-option events that may fire before queued session entries persist while busy. +- Verify `before_agent_start` hook semantics against coding-agent. +- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets. +- Document or change runtime config event timing while busy. - Audit `abort()` barrier semantics. -### 7. Complete low-level `Result` cleanup +### 4. Implement generic hook/event extension mechanism -Current hardening pass complete; future items remain as the API evolves. +Status: Designed in [hooks.md](./hooks.md), not implemented -Implemented so far: +Done: -- Added generic `Result` plus helpers. -- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. -- Split filesystem/shell capabilities and moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. -- Added `ExecutionEnv.appendFile()` for streaming append use cases. -- Updated skill and prompt-template loaders to consume `ExecutionEnv` results. -- Updated shell output capture to return a result and use `ExecutionEnv` instead of Node APIs directly, including full-output spill via `appendFile()`. -- Removed `NodeExecutionEnv` from browser-safe root exports; Node-specific callers use the `node` entry point or `harness/env/nodejs.js`. -- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. -- Converted compaction summary helpers to typed result returns and added error-path coverage. -- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output full-output spill. -- Added `readTextLines()` so JSONL metadata loading reads only the header line instead of whole session files. -- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful while keeping the `FileSystem` interface unchanged. -- Mapped filesystem errors crossing the session boundary to typed `SessionError`. -- Added typed branch-summary errors and cause-aware public harness error normalization. -- Made resource loaders report structured diagnostics for non-`not_found` filesystem failures. +- Removed `AgentHarnessContext`. +- Hooks receive only event payloads. +- `emitHook(event)` derives the hook type from `event.type`. +- Provider request/payload hooks have ordered transform semantics. -Ongoing guardrails: +Remaining: -- Keep low-level capability/helper APIs non-throwing where they return `Result`. -- Keep session storage/repo/session APIs throwing typed `SessionError`. -- Keep structural `AgentHarness` operations rejecting with `AgentHarnessError` for busy, missing-resource, auth, compaction, and branch-summary failures. -- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts` and Node-backed storage/session implementations, or move those implementations behind explicit Node-only entry points. -- Audit remaining generic harness utilities for Node globals as new APIs are added. -- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv`. -- Keep expanding `ExecutionEnv` and shell-output contract tests as the API evolves, especially for non-Node implementations. -- Add tests proving public harness failures reject with `AgentHarnessError` where expected. +- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation. +- Move result chaining out of `AgentHarness` into reducer functions. +- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics. +- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks. +- Define the initial harness/context facades exposed through hook context. +- Preserve current provider hook behavior, including stream option patch deletion semantics. +- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage. -### 8. Later coding-agent migration plan +### 5. Final lifecycle hardening suite -Not started. +Status: Planned -Still needed: +Done: + +- None. + +Remaining: + +- Add broad listener/hook reentrancy tests across relevant events. +- Test runtime config setters from low-level lifecycle events and harness events. +- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options. +- Test resource/tool/model/thinking/stream-option updates during active turns and save points. +- Test session writes from listeners and hooks, including `settled` writes. +- Test queue operations from turn events, tool events, and provider hooks. +- Test rejected structural operations while busy. +- Test abort from listeners/hooks. +- Test getter behavior during active operations. +- Test deterministic ordering of agent-emitted messages and pending listener writes. +- Test no deadlocks when async listeners call harness APIs and await them. +- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation. + +### 6. Later coding-agent migration plan + +Status: Planned + +Done: + +- None. + +Remaining: - Map coding-agent resources to sourced loaders. - Keep app-level resource dedupe/provenance outside the harness. -- Adapt extension loader to the future hook/session facade. +- Adapt extension loading to the future hook/session facade. - Preserve UI/session behavior outside core. -- Move coding-agent stream/auth/retry/header behavior onto the harness stream configuration and provider hooks. +- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks. -### 9. Final lifecycle hardening suite +--- -Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event. +## Completed implementation todo -Needs broad tests for: +### 7. Remove `Agent` dependency from `AgentHarness` -- runtime config setters from low-level lifecycle events and harness events -- uniform runtime config observability events for model, thinking, resources, tools, active tools, and stream options -- resource/tool/model/thinking/stream-option updates during active turns and save points -- session writes from listeners and hooks, including writes from `settled` -- queue operations from turn events, tool events, and provider hooks -- rejected structural operations while busy -- abort from listeners/hooks -- getter behavior during active operations -- deterministic ordering of agent-emitted messages and pending listener writes -- no deadlocks when async listeners call harness APIs and await them -- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation +Status: Done + +Done: + +- `AgentHarness` calls `runAgentLoop()` directly. +- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots. +- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping. + +Remaining: + +- None. + +Notes: + +- Broader listener/hook reentrancy coverage is tracked in item 5. + +### 8. Finish curated provider/stream configuration + +Status: Done + +Done: + +- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`. +- Stream options, headers, metadata, and derived session id are snapshotted per turn. +- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop. +- `getApiKeyAndHeaders()` resolves credentials per provider request. +- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented. +- Stream option patching supports explicit field deletion and ordered hook chaining. +- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior. + +Remaining: + +- None. + +### 9. Complete low-level `Result` cleanup + +Status: Done + +Done: + +- Added generic `Result` plus helpers. +- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. +- Split filesystem and shell capabilities. +- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. +- Added `ExecutionEnv.appendFile()` for streaming append use cases. +- Updated skill and prompt-template loaders to consume `ExecutionEnv` results. +- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`. +- Removed `NodeExecutionEnv` from browser-safe root exports. +- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. +- Converted compaction and branch-summary helpers to typed result returns. +- Added `readTextLines()` so JSONL metadata loading reads only the header line. +- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful. +- Mapped filesystem errors crossing the session boundary to typed `SessionError`. +- Added typed branch-summary errors and cause-aware public harness error normalization. +- Resource loaders report structured diagnostics for non-`not_found` filesystem failures. +- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill. + +Remaining: + +- None. + +Notes: + +- Keep low-level capability/helper APIs non-throwing where they return `Result`. +- Keep session storage/repo/session APIs throwing typed `SessionError`. +- Keep public structural harness failures normalized to `AgentHarnessError`. +- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points. +- Audit generic harness utilities for Node globals as APIs are added. +- Audit package exports so browser/generic imports do not pull Node-only modules. +- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve. diff --git a/packages/agent/docs/hooks.md b/packages/agent/docs/hooks.md new file mode 100644 index 00000000..c15bb5dc --- /dev/null +++ b/packages/agent/docs/hooks.md @@ -0,0 +1,465 @@ +# AgentHarness hooks design + +This document describes the target hook system for `AgentHarness` and app-specific harness integrations. + +## Goals + +- `AgentHarness` emits hook events and consumes typed results. +- Hook registration, provenance, cleanup, and mutation-chain semantics live in the hooks implementation. +- There is one registration API and one emission API. +- Observational and mutation hooks use the same registration API; the event result type determines whether a handler can return a result. +- Apps can extend the event union, context type, source/provenance type, and reducers without changing `AgentHarness`. +- Resources and tools carry provenance on their app-specific concrete value types. Hook handlers carry provenance as registration sidecar metadata. + +## Value provenance + +For non-hook values, provenance belongs on the app-specific concrete type. + +```ts +interface AppSource { + path: string; + scope: "user" | "project" | "temporary"; +} + +type AppSkill = Skill & { source: AppSource }; +type AppPromptTemplate = PromptTemplate & { source: AppSource }; +type AppTool = AgentTool & { source: AppSource }; +``` + +The harness already accepts generic resource/tool types, so no wrapper such as `{ value, source }` is needed. + +```ts +const harness = new AgentHarness({ + resources: { skills, promptTemplates }, + tools, + // ... +}); +``` + +Loaders such as `loadSourcedSkills()` and `loadSourcedPromptTemplates()` can map source metadata onto the concrete app value type before passing values to the harness. + +## Hook event typing + +Each hook event owns its handler result type through a type-only phantom field. + +```ts +declare const HookResult: unique symbol; + +export interface HookEvent { + type: TType; + readonly [HookResult]?: TResult; +} + +export type ResultOf = TEvent extends { readonly [HookResult]?: infer TResult } ? TResult : void; +``` + +Observational events omit the result type: + +```ts +interface MessageStartEvent extends HookEvent<"message_start"> { + type: "message_start"; + message: AgentMessage; +} +``` + +Mutation/policy events declare their result type: + +```ts +interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> { + type: "context"; + messages: AgentMessage[]; +} + +interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> { + type: "tool_call"; + toolName: string; + input: Record; +} +``` + +There is no central result map and no event spec table. The event type itself defines the return type handlers may produce. + +## Hook handlers and registration options + +Handlers are plain functions. Provenance and cleanup live on the registration. + +```ts +export type HookCleanup = () => void | Promise; + +export type HookHandler = ( + event: TEvent, + context: TContext, + signal?: AbortSignal, +) => ResultOf | void | Promise | void>; + +export interface HookRegistrationOptions { + source?: TSource; + cleanup?: HookCleanup; +} +``` + +Example: + +```ts +hooks.on( + "context", + (event, context) => ({ messages: injectContext(event.messages, context) }), + { + source: extensionSource, + cleanup: () => cache.dispose(), + }, +); +``` + +The cleanup runs once, either when the returned unregister function is called, or when `clear()` / `dispose()` clears the registration. + +## Reducers + +Result-producing events need reducers. Observational events do not. + +```ts +type ResultfulEvent = TEvent extends HookEvent + ? [TResult] extends [void] + ? never + : TEvent + : never; + +type HookRegistration = { + handler: HookHandler; + source?: TSource; + cleanup?: HookCleanup; + disposed: boolean; + order: number; +}; + +type Reducer = ( + event: TEvent, + registrations: readonly HookRegistration[], + context: TContext, + signal?: AbortSignal, +) => Promise | undefined>; + +type Reducers = { + [TType in ResultfulEvent["type"]]: Reducer< + Extract, { type: TType }>, + TContext, + TSource + >; +}; +``` + +Reducers encode hook semantics, for example: + +- `context`: sequential transform; each handler sees current messages. +- `before_provider_request`: sequential patch/transform; each handler sees current request state. +- `before_provider_payload`: sequential payload transform. +- `before_agent_start`: chain `systemPrompt`; collect injected messages. +- `tool_call`: same mutable event/input visible to later handlers; first `{ block: true }` stops. +- `tool_result`: sequential patch accumulation; each handler sees current patched result. +- `message_end`: sequential message replacement; replacement must keep the original role. +- `session_before_*`: first `{ cancel: true }` stops; otherwise return the last meaningful result. + +Base harness reducers are defined once: + +```ts +const agentHarnessReducers = { + context: reduceContext, + before_provider_request: reduceBeforeProviderRequest, + before_provider_payload: reduceBeforeProviderPayload, + before_agent_start: reduceBeforeAgentStart, + tool_call: reduceToolCall, + tool_result: reduceToolResult, + message_end: reduceMessageEnd, + session_before_compact: reduceFirstCancelOrLast, + session_before_tree: reduceFirstCancelOrLast, +} satisfies Reducers; +``` + +If `AgentHarnessEvent` gains a new result-producing event, TypeScript forces the reducer table to be updated. + +## Single hooks implementation + +The hooks implementation stores registrations and runs reducers. + +```ts +class AgentHarnessHooks< + TEvent extends HookEvent, + TContext, + TSource = unknown, +> { + context: TContext; + + constructor( + context: TContext, + extraReducers?: ExtraReducers, + ) { + this.context = context; + this.reducers = { + ...agentHarnessReducers, + ...extraReducers, + } as Reducers; + } + + setContext(context: TContext): void { + this.context = context; + } + + on( + type: TType, + handler: HookHandler, TContext>, + options?: HookRegistrationOptions, + ): () => Promise { + // Store the registration and return unregister. + } + + async emit( + event: TEmittedEvent, + signal?: AbortSignal, + ): Promise | undefined> { + const registrations = this.getRegistrations(event.type); + const reducer = this.reducers[event.type as keyof typeof this.reducers]; + + if (reducer) { + return reducer(event as never, registrations as never, this.context, signal) as Promise< + ResultOf | undefined + >; + } + + for (const registration of registrations) { + await registration.handler(event, this.context, signal); + } + + return undefined; + } + + async clear(): Promise { + // Remove all registrations and run remaining cleanups once in reverse registration order. + } + + dispose(): Promise { + return this.clear(); + } +} +``` + +Public API: + +```ts +hooks.on(...); +hooks.emit(...); +hooks.clear(); +hooks.dispose(); +``` + +There is no wildcard subscription and no separate observer API. + +## App-specific events and reducers + +Apps extend the event union. + +Observational app events need no reducer: + +```ts +interface SessionStartEvent extends HookEvent<"session_start"> { + type: "session_start"; + reason: "startup" | "reload" | "new" | "resume" | "fork"; +} +``` + +Result-producing app events need an extra reducer: + +```ts +type InputResult = + | { action: "continue" } + | { action: "transform"; text: string; images?: ImageContent[] } + | { action: "handled" }; + +interface InputEvent extends HookEvent<"input", InputResult> { + type: "input"; + text: string; + images?: ImageContent[]; + source: "interactive" | "rpc" | "extension"; +} +``` + +```ts +const codingAgentExtraReducers = { + input: reduceInput, + user_bash: reduceFirstResult, + resources_discover: reduceResourcesDiscover, + session_before_switch: reduceFirstCancelOrLast, + session_before_fork: reduceFirstCancelOrLast, +} satisfies ExtraReducers; +``` + +Base reducers are included by the hooks constructor. Apps only provide reducers for app-specific result-producing events. + +```ts +type CodingAgentEvent = + | AgentHarnessEvent + | SessionStartEvent + | SessionShutdownEvent + | InputEvent + | UserBashEvent + | ResourcesDiscoverEvent; + +const hooks = new AgentHarnessHooks( + context, + codingAgentExtraReducers, +); +``` + +## Harness typing + +`AgentHarness` stores and exposes the concrete hooks object. + +```ts +type DefaultHooks = AgentHarnessHooks< + AgentHarnessEvent, + undefined, + unknown +>; + +class AgentHarness< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, + THooks = DefaultHooks, +> { + readonly hooks: THooks; + + constructor(options: AgentHarnessOptions) { + this.hooks = options.hooks ?? createDefaultHooks(); + } +} +``` + +When custom hooks are passed, TypeScript infers `THooks` from `options.hooks`. + +```ts +const hooks = new CodingAgentHooks(context, codingAgentExtraReducers); + +const harness = new AgentHarness({ + model, + session, + hooks, + resources, + tools, +}); + +harness.hooks; // CodingAgentHooks +``` + +Custom app APIs live on `harness.hooks`; they are not proxied onto `AgentHarness`. + +## Harness usage + +The harness only emits events and uses typed results. + +```ts +await this.hooks.emit({ type: "message_start", message }, signal); +``` + +```ts +const result = await this.hooks.emit({ type: "context", messages }, signal); +messages = result?.messages ?? messages; +``` + +```ts +const result = await this.hooks.emit({ type: "tool_call", toolName, input }, signal); +if (result?.block) return blockedToolResult(result.reason); +``` + +`AgentHarness` does not store handlers and does not implement hook chaining semantics. + +## Context model + +Context is a plain object owned by the hooks implementation. + +```ts +hooks.setContext(nextContext); +``` + +Per-run `AbortSignal` is passed separately to `emit()` and handlers. + +Dynamic app state should be exposed through small facades instead of late-bound getter mazes. + +Example app context: + +```ts +interface CodingAgentContext { + harness: HarnessFacade; + session: SessionFacade; + ui: UiFacade; + models: ModelFacade; +} +``` + +The hook context should not expose `waitForIdle()` to hook handlers. A future facade can expose `runWhenIdle(() => Promise)` for safe deferred work. + +## Cleanup semantics + +Each registration owns at most one cleanup. + +- Manual unregister removes the registration and runs its cleanup once. +- `clear()` removes all remaining registrations and runs their cleanups once. +- `dispose()` calls `clear()`. +- Cleanup order is reverse registration order. +- Cleanup errors are collected; cleanup continues; `clear()` throws an aggregate error if any cleanup failed. + +## Error policy + +The base hooks implementation can throw handler errors by default. + +App-specific hooks that load untrusted/user extensions should use a continue-and-report policy. Reducers receive registration source metadata so they can report errors with provenance: + +```ts +for (const registration of registrations) { + try { + const result = await registration.handler(event, context, signal); + // apply result + } catch (error) { + reportHookError({ + event: event.type, + source: registration.source, + error, + }); + } +} +``` + +## Extension loading sketch + +An app-level extension host owns extension loading and non-hook registries. The harness only receives hooks. + +```ts +class ExtensionHost { + constructor(private readonly hooks: AgentHarnessHooks) {} + + async load(paths: string[]): Promise { + for (const path of paths) { + const extension = await loadExtension(path); + const source = createExtensionSource(path); + + const api = { + on: (type, handler, cleanup) => { + this.hooks.on(type, handler, { source, cleanup }); + }, + registerTool: (tool) => { + this.tools.set(tool.name, { ...tool, source }); + }, + }; + + await extension(api); + } + } + + async clear(): Promise { + this.tools.clear(); + this.commands.clear(); + await this.hooks.clear(); + } +} +``` + +Non-hook registries, such as tools, commands, flags, shortcuts, message renderers, providers, and OAuth providers, remain app-level concerns.