docs(agent): document harness hook design
This commit is contained in:
@@ -158,14 +158,19 @@ If the system-prompt callback throws while starting `prompt`, `skill`, or `promp
|
|||||||
|
|
||||||
## Hooks and events
|
## 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.
|
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<void>)` scheduling API instead. This is future extension-context work; current listeners/hooks receive only payloads and no safe harness facade.
|
|
||||||
|
|
||||||
## Planned session 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.
|
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
|
## 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<TSkill, TPromptTemplate, TTool>`.
|
||||||
|
- 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
|
Remaining:
|
||||||
- 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 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.
|
Done:
|
||||||
- `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.
|
|
||||||
|
|
||||||
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.
|
Remaining:
|
||||||
- `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:
|
|
||||||
|
|
||||||
- Decide how applications supply the model registry.
|
- Decide how applications supply the model registry.
|
||||||
- Decide whether the harness stores concrete `Model` objects, model references, or both.
|
- Decide whether the harness stores concrete `Model` objects, model references, or both.
|
||||||
- Validate model selection against the registry.
|
- Validate model selection against the registry.
|
||||||
- Define model change semantics during active turns and save points.
|
- 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`.
|
Done:
|
||||||
- Hooks receive only event payloads.
|
|
||||||
- `emitHook(event)` derives the hook type from `event.type`.
|
|
||||||
|
|
||||||
Still needed:
|
- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`.
|
||||||
|
|
||||||
- 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<void>)`; 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<TSkill, TPromptTemplate, TTool>`
|
|
||||||
- `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()`.
|
|
||||||
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
|
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
|
||||||
- Low-level `AgentLoopConfig.prepareNextTurn` save-point update exists.
|
- Added explicit `phase` in place of boolean idle state.
|
||||||
- `prepareNextTurn` updates low-level context/model/thinking-level and harness-applied stream/session snapshot state.
|
- Save points refresh context, model, thinking level, stream options, and session snapshot state.
|
||||||
- The loop converts `ThinkingLevel` to provider `reasoning` internally.
|
- Pending session writes use session-entry shapes without generated fields.
|
||||||
- `phase` replaces boolean idle.
|
|
||||||
- Pending session writes are based on session-entry shapes without generated fields.
|
|
||||||
- Pending session writes flush at save points, settlement, and failure cleanup.
|
- Pending session writes flush at save points, settlement, and failure cleanup.
|
||||||
- `steer`, `followUp`, and `nextTurn` accept text plus optional images and create `UserMessage` internally.
|
- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images.
|
||||||
- `nextTurn` ordering is fixed: queued messages before the new user message.
|
- `nextTurn` messages are inserted before the new user prompt.
|
||||||
- Removed `liveOperationId`.
|
|
||||||
- Removed `shell()`; use `harness.env`.
|
|
||||||
|
|
||||||
Implemented in the hardening pass:
|
|
||||||
|
|
||||||
- Structural compaction/tree operations restore phase with `finally`.
|
- Structural compaction/tree operations restore phase with `finally`.
|
||||||
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
|
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
|
||||||
- Pending session writes flush one-by-one and are not dropped on failure.
|
- 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.
|
- Idle model/thinking/tool updates validate and persist before committing in-memory state.
|
||||||
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
|
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
|
||||||
|
|
||||||
Still needed:
|
Remaining:
|
||||||
|
|
||||||
- Finalize phase/idle semantics.
|
- Finalize phase/idle semantics.
|
||||||
- Audit whether `settled` can fire too early.
|
- Audit whether `settled` can fire too early.
|
||||||
@@ -388,72 +310,144 @@ Still needed:
|
|||||||
- Audit follow-up behavior around `agent_end`.
|
- Audit follow-up behavior around `agent_end`.
|
||||||
- Implement auto-compaction decision point.
|
- Implement auto-compaction decision point.
|
||||||
- Implement retry handling.
|
- Implement retry handling.
|
||||||
- Verify `before_agent_start` hook semantics against coding-agent:
|
- Verify `before_agent_start` hook semantics against coding-agent.
|
||||||
- current behavior appends returned messages after the user/next-turn prompt messages.
|
- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets.
|
||||||
- decide whether replacement, prepend, append, or transform semantics are correct.
|
- Document or change runtime config event timing while busy.
|
||||||
- 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.
|
|
||||||
- Audit `abort()` barrier semantics.
|
- 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<TValue, TError>` plus helpers.
|
- Removed `AgentHarnessContext`.
|
||||||
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
|
- Hooks receive only event payloads.
|
||||||
- Split filesystem/shell capabilities and moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
|
- `emitHook(event)` derives the hook type from `event.type`.
|
||||||
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
|
- Provider request/payload hooks have ordered transform semantics.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Ongoing guardrails:
|
Remaining:
|
||||||
|
|
||||||
- Keep low-level capability/helper APIs non-throwing where they return `Result`.
|
- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation.
|
||||||
- Keep session storage/repo/session APIs throwing typed `SessionError`.
|
- Move result chaining out of `AgentHarness` into reducer functions.
|
||||||
- Keep structural `AgentHarness` operations rejecting with `AgentHarnessError` for busy, missing-resource, auth, compaction, and branch-summary failures.
|
- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics.
|
||||||
- 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.
|
- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks.
|
||||||
- Audit remaining generic harness utilities for Node globals as new APIs are added.
|
- Define the initial harness/context facades exposed through hook context.
|
||||||
- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv`.
|
- Preserve current provider hook behavior, including stream option patch deletion semantics.
|
||||||
- Keep expanding `ExecutionEnv` and shell-output contract tests as the API evolves, especially for non-Node implementations.
|
- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage.
|
||||||
- Add tests proving public harness failures reject with `AgentHarnessError` where expected.
|
|
||||||
|
|
||||||
### 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.
|
- Map coding-agent resources to sourced loaders.
|
||||||
- Keep app-level resource dedupe/provenance outside the harness.
|
- 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.
|
- 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
|
Status: Done
|
||||||
- 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
|
Done:
|
||||||
- session writes from listeners and hooks, including writes from `settled`
|
|
||||||
- queue operations from turn events, tool events, and provider hooks
|
- `AgentHarness` calls `runAgentLoop()` directly.
|
||||||
- rejected structural operations while busy
|
- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots.
|
||||||
- abort from listeners/hooks
|
- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping.
|
||||||
- getter behavior during active operations
|
|
||||||
- deterministic ordering of agent-emitted messages and pending listener writes
|
Remaining:
|
||||||
- no deadlocks when async listeners call harness APIs and await them
|
|
||||||
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
|
- 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<TValue, TError>` 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.
|
||||||
|
|||||||
465
packages/agent/docs/hooks.md
Normal file
465
packages/agent/docs/hooks.md
Normal file
@@ -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<AppSkill, AppPromptTemplate, AppTool>({
|
||||||
|
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<TType extends string, TResult = void> {
|
||||||
|
type: TType;
|
||||||
|
readonly [HookResult]?: TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResultOf<TEvent> = 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<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
|
||||||
|
export type HookHandler<TEvent, TContext> = (
|
||||||
|
event: TEvent,
|
||||||
|
context: TContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => ResultOf<TEvent> | void | Promise<ResultOf<TEvent> | void>;
|
||||||
|
|
||||||
|
export interface HookRegistrationOptions<TSource> {
|
||||||
|
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> = TEvent extends HookEvent<string, infer TResult>
|
||||||
|
? [TResult] extends [void]
|
||||||
|
? never
|
||||||
|
: TEvent
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type HookRegistration<TContext, TSource> = {
|
||||||
|
handler: HookHandler<any, TContext>;
|
||||||
|
source?: TSource;
|
||||||
|
cleanup?: HookCleanup;
|
||||||
|
disposed: boolean;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Reducer<TEvent, TContext, TSource> = (
|
||||||
|
event: TEvent,
|
||||||
|
registrations: readonly HookRegistration<TContext, TSource>[],
|
||||||
|
context: TContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<ResultOf<TEvent> | undefined>;
|
||||||
|
|
||||||
|
type Reducers<TEvent, TContext, TSource> = {
|
||||||
|
[TType in ResultfulEvent<TEvent>["type"]]: Reducer<
|
||||||
|
Extract<ResultfulEvent<TEvent>, { 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<AgentHarnessEvent, AgentHarnessContext, unknown>;
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
TContext,
|
||||||
|
TSource = unknown,
|
||||||
|
> {
|
||||||
|
context: TContext;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
context: TContext,
|
||||||
|
extraReducers?: ExtraReducers<TEvent, AgentHarnessEvent, TContext, TSource>,
|
||||||
|
) {
|
||||||
|
this.context = context;
|
||||||
|
this.reducers = {
|
||||||
|
...agentHarnessReducers,
|
||||||
|
...extraReducers,
|
||||||
|
} as Reducers<TEvent, TContext, TSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext(context: TContext): void {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
on<TType extends TEvent["type"]>(
|
||||||
|
type: TType,
|
||||||
|
handler: HookHandler<Extract<TEvent, { type: TType }>, TContext>,
|
||||||
|
options?: HookRegistrationOptions<TSource>,
|
||||||
|
): () => Promise<void> {
|
||||||
|
// Store the registration and return unregister.
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit<TEmittedEvent extends TEvent>(
|
||||||
|
event: TEmittedEvent,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ResultOf<TEmittedEvent> | 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<TEmittedEvent> | undefined
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.handler(event, this.context, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
// Remove all registrations and run remaining cleanups once in reverse registration order.
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): Promise<void> {
|
||||||
|
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<CodingAgentEvent, AgentHarnessEvent, CodingAgentContext, AppSource>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Base reducers are included by the hooks constructor. Apps only provide reducers for app-specific result-producing events.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CodingAgentEvent =
|
||||||
|
| AgentHarnessEvent<AppSkill, AppPromptTemplate, AppTool>
|
||||||
|
| SessionStartEvent
|
||||||
|
| SessionShutdownEvent
|
||||||
|
| InputEvent
|
||||||
|
| UserBashEvent
|
||||||
|
| ResourcesDiscoverEvent;
|
||||||
|
|
||||||
|
const hooks = new AgentHarnessHooks<CodingAgentEvent, CodingAgentContext, AppSource>(
|
||||||
|
context,
|
||||||
|
codingAgentExtraReducers,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Harness typing
|
||||||
|
|
||||||
|
`AgentHarness` stores and exposes the concrete hooks object.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DefaultHooks<TSkill, TPromptTemplate, TTool> = AgentHarnessHooks<
|
||||||
|
AgentHarnessEvent<TSkill, TPromptTemplate, TTool>,
|
||||||
|
undefined,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
class AgentHarness<
|
||||||
|
TSkill extends Skill = Skill,
|
||||||
|
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||||
|
TTool extends AgentTool = AgentTool,
|
||||||
|
THooks = DefaultHooks<TSkill, TPromptTemplate, TTool>,
|
||||||
|
> {
|
||||||
|
readonly hooks: THooks;
|
||||||
|
|
||||||
|
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool, THooks>) {
|
||||||
|
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<void>)` 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<CodingAgentEvent, CodingAgentContext, AppSource>) {}
|
||||||
|
|
||||||
|
async load(paths: string[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user