Skip to content

feat: add HooksManager lifecycle hook system to callModel#7

Open
mattapperson wants to merge 10 commits intomainfrom
feat/hooks-manager
Open

feat: add HooksManager lifecycle hook system to callModel#7
mattapperson wants to merge 10 commits intomainfrom
feat/hooks-manager

Conversation

@mattapperson
Copy link
Copy Markdown
Collaborator

Summary

  • Adds an extensible hook system for callModel inspired by the Claude Agent SDK hooks pattern
  • Two usage modes: inline config (plain object for quick setup) and HooksManager class (custom hooks, dynamic registration, programmatic emit)
  • 8 built-in hooks: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, PermissionRequest, SessionStart, SessionEnd
  • Features: tool matchers (string/RegExp/function), filter predicates, mutation piping, short-circuit on block/reject, async fire-and-forget handlers, configurable error handling
  • Custom hook definitions via Zod schema pairs with full TypeScript autocomplete
  • Additive API — existing onTurnStart, onTurnEnd, requireApproval remain unchanged

Re-creation of OpenRouterTeam/openrouter-web#16735 on the standalone agent SDK repo.

Test plan

  • 35 new unit tests across 4 test files (matchers, emit engine, manager class, resolver)
  • All 186 unit tests pass
  • Build (pnpm build) passes cleanly
  • Lint (pnpm lint) passes cleanly
  • E2E testing with real API calls (PreToolUse block, mutation piping, Stop forceResume)

Implements an extensible hook system for callModel inspired by the
Claude Agent SDK hooks pattern. Supports inline config for quick
declarative usage and a HooksManager class for custom hooks,
dynamic registration, and programmatic emit.

Built-in hooks: PreToolUse, PostToolUse, PostToolUseFailure,
UserPromptSubmit, Stop, PermissionRequest, SessionStart, SessionEnd.

Features: tool matchers (string/RegExp/function), filter predicates,
mutation piping, short-circuit on block/reject, async fire-and-forget
handlers, configurable error handling, and custom hook definitions
via Zod schema pairs.
81 edge-case tests covering handler chain execution, manager lifecycle,
tool matchers, resolver normalization, and async output detection.
Surfaces real issues: filter throws bypass error handling, global RegExp
stateful .test(), and function matcher lacking boolean coercion.
test: add adversarial unit tests for hooks system
Resolves conflicts in src/index.ts (keep both hooks exports and
new OpenRouter/SDKOptions exports) and src/inner-loop/call-model.ts
(keep both resolveHooks and Tool type imports).

Adapts hooks files to Biome linter rules introduced on main:
import ordering, block statements, and sparse-array/void-type
suppressions where the behavior is intentional.
Fixes CI failure on `validate` job by applying Biome's auto-fixes for
import sorting in async-params.ts and model-result.ts, and formatting
in hooks-matchers.ts, hooks-schemas.ts, model-result.ts, and
conversation-state.test.ts.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed, no issues found

Integrate the Turborepo monorepo migration (src/ -> packages/agent/src/,
tests/ -> packages/agent/tests/, tsconfig.json -> tsconfig.base.json) with
the HooksManager lifecycle hook system from this PR.

All hooks files (hooks-*.ts) and their tests were relocated under
packages/agent/ to match the new workspace layout.

Renamed the re-exported SDK HookContext to SDKHookContext in
packages/agent/src/index.ts to disambiguate from the lifecycle hook
context we already export from lib/hooks-types.ts. The SDK-level fix
from ef15761 (normalize hooks constructor option) is preserved intact
from main.
Library internals:
- AsyncOutput.work field enables true fire-and-forget; chain tracks
  background work with timeout instead of advertising it cosmetically
- HooksManager.emit now validates payloads and handler results against
  their Zod schemas; stored customHooks registry drives custom-hook
  validation
- MUTATION_FIELD_MAP scoped per-hook so only PreToolUse maps
  mutatedInput->toolInput and only UserPromptSubmit maps
  mutatedPrompt->prompt; custom hooks opt out by default
- pendingAsync converted to a self-cleaning Set so it no longer leaks
  across long sessions
- Per-emit AbortController tracked on the manager; abortInflight()
  cancels every in-flight emit's HookContext.signal
- Matcher without toolName now fails closed (skipped) instead of open
- isBlockTriggered rejects empty strings to stay consistent with the
  truthy reason search in model-result
- registerEntry hidden behind a module-private symbol + helper so
  resolveHooks can still register inline configs without exposing a
  back door to user code

model-result integration:
- Fire Pre/Post/PostFailure hooks from every tool-execution path by
  routing through a new runToolWithHooks helper (auto, auto-approved,
  and manually approved calls are all covered)
- Emit PermissionRequest in handleApprovalCheck; allow/deny/ask_user
  decisions gate the approval flow, denied calls synthesize an error
  result without executing
- UserPromptSubmit now fires before stateful input-wrapping, handles
  both string and structured message-array inputs, and applies
  mutations back to the base request
- Cap Stop forceResume at 3 consecutive overrides to prevent infinite
  loops when a stop condition keeps firing
- Check .some(forceResume) across all Stop results and honor
  appendPrompt by injecting a user message into the next turn
- SessionStart config populated with {hasTools, hasApproval, hasState}
  rather than hardcoded undefined
- Swap Date.now() -> performance.now() for durationMs timing
Copy link
Copy Markdown
Collaborator

@robert-j-y robert-j-y left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. SessionEnd fires on exactly one code path: a completed run that had tools AND made at least one tool call AND didn't throw.

packages/agent/src/lib/model-result.ts:1985-2184 — the execute IIFE has no try/catch/finally (grepped: only two try blocks in the file, at line 1587 and 2758, both unrelated). The only SessionEnd emit is at line 2178, at the very bottom, after four unconditional early returns:

  • line 1990: resuming from approval, still pending
  • line 2007: !this.options.tools?.length || !hasToolCallsa plain chat response with no tool calls never emits SessionEnd
  • line 2015: paused for approval
  • line 2021: no executable tool calls

Plus any throw in the loop (tool exec, hook emit, state save, followup request) rejects the promise and skips SessionEnd + hooksManager.drain(). SessionEndPayload.reason at hooks-types.ts:177 declares 'user' | 'error' | 'max_turns' | 'complete'; only 'complete' is ever emitted, and then only on the happy-tool path.

Fix: wrap the IIFE body in try { ... } finally { if (this.hooksManager) { await this.hooksManager.emit('SessionEnd', { sessionId, reason }); await this.hooksManager.drain(); } }, and pick reason based on exit path ('complete' on normal end, 'error' in the catch, 'max_turns' when the loop exits via shouldStopExecution without a Stop override).

2. Stop payload reason is hardcoded to 'end_turn' — verified as the only emit site.

model-result.ts:2043-2046:

const stopResult = await this.hooksManager.emit('Stop', {
  reason: 'end_turn' as const,
  sessionId: this.currentState?.id ?? '',
});

Grepped the whole file: one Stop emit site. StopPayload at hooks-types.ts:154 declares 'end_turn' | 'max_tokens' | 'stop_sequence' — the other two are unreachable. Also: shouldStopExecution() fires off arbitrary stopWhen conditions (default stepCountIs(DEFAULT_MAX_STEPS)), which semantically is closer to 'max_turns' than 'end_turn'. The three enum values are Anthropic-style stop reasons and don't cleanly map to this engine's stop causes.

Fix: either derive reason from the actual cause (stop-condition name vs. response finish_reason) and expand the schema to cover step-count/tool-absence cases, or drop the two unreachable variants until they're wired.

3. forceResumeCount is a lifetime counter; comment says "without new progress".

model-result.ts:2031 declares let forceResumeCount = 0; outside the while. Only mutation is forceResumeCount++ at line 2083. Never reset. So a Stop hook that force-resumes at turn 2, runs 50 clean turns, force-resumes at turn 53, runs 50 more, force-resumes at turn 104 — gets cut off on the third call despite 100 turns of real progress in between.

The console.warn at line 2079 asserts "forceResume honored 3 times without new progress", which the code does not enforce.

Fix: either reset forceResumeCount = 0 at the top of each while iteration that doesn't enter the shouldStopExecution() branch, or change the warning text and the comment at 2026-2030 to say "3 times total this session".

Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed, no issues found

…n emit

Disambiguates the lifecycle hook context type from the SDK-level HookContext
re-exported from @openrouter/sdk. Tightens executeHandlerChain to avoid
spread-cloning non-plain payloads, threads hookName into async-work tracking
for clearer rejection logs, and prefers payload sessionId over the manager's
stored value. Narrows StopPayload.reason to 'max_turns' to match the actual
emission site.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial pass surfaced seven issues across the emit engine, matcher layer, and SessionStart/End pairing.

Comment thread packages/agent/src/lib/hooks-manager.ts Outdated
Comment thread packages/agent/src/lib/hooks-matchers.ts
Comment thread packages/agent/src/lib/hooks-types.ts
Comment thread packages/agent/src/lib/model-result.ts
Comment thread packages/agent/src/lib/hooks-emit.ts
Comment thread packages/agent/tests/unit/hooks-manager-adversarial.test.ts Outdated
Comment thread packages/agent/src/lib/hooks-resolve.ts
Address seven PR review threads on feat/hooks-manager:

- emit() snapshots its entries list before running the chain, so
  handlers that call off()/unsubscribe mid-chain can't shift indices
  and silently skip the next handler.
- matchesTool resets `matcher.lastIndex = 0` before .test(), making
  /g and /y RegExps idempotent across successive emits.
- isAsyncOutput now requires `async: true` AND no foreign keys. A
  handler that returns { async: true, mutatedInput: ... } is treated
  as a result, not fire-and-forget, so mutation piping isn't dropped.
- executeToolsIfNeeded guards the SessionEnd emit on a new
  sessionStartEmitted flag so an initStream throw before SessionStart
  can't produce a dangling SessionEnd for Start/End-paired handlers.
- executeHandlerChain pushes `validation.data` onto results when a
  schema is present, so custom hooks using .transform/.default/.catch
  see the parsed output rather than the raw input.
- resolveHooks warns and skips non-built-in hook names in inline
  config to catch typos like `PretoolUse` vs `PreToolUse`; custom
  hooks still require a HooksManager instance.
- Adversarial test for mid-emit on() now asserts the snapshotted
  behavior (late handler not invoked) instead of the former
  index-drift footgun.

Existing adversarial tests for the old permissive behaviors
(/g drift, arbitrary inline hook names, `async: true` + extras
treated as fire-and-forget) are updated to pin the tighter
invariants.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the hooks engine and model-result integration; 5 concerns worth a second look on abort semantics, argument normalization, parse-error handling, clone depth, and timer lifetime.

Comment thread packages/agent/src/lib/hooks-emit.ts
Comment thread packages/agent/src/lib/model-result.ts Outdated
Comment thread packages/agent/src/lib/model-result.ts
Comment thread packages/agent/src/lib/hooks-emit.ts
Comment thread packages/agent/src/lib/hooks-emit.ts
- executeHandlerChain checks context.signal.aborted between handlers so
  abortInflight() has a deterministic effect on the chain itself rather
  than relying on each handler to consult the signal. An already-aborted
  emit skips every handler.
- Added a Node-only unref() on the setTimeout handle backing async-work
  tracking so a leaked fire-and-forget hook whose work never settles
  can't hold the event loop open for the full 30s timeout window. Guarded
  for browsers that return a plain number from setTimeout.
- Clarified the shallow-clone docstring on executeHandlerChain to make
  the top-level-only isolation explicit; nested mutations by handlers
  still reach the caller, so handlers MUST pipe mutations via the
  documented result fields.
- runToolWithHooks rejects raw-string toolCall.arguments (the shape the
  parser leaves behind on a JSON.parse failure) before any hook fires
  and returns a 'parse_error' result with a prebuilt synthetic error
  output. executeToolRound, executeAutoApproveTools, and
  processApprovalDecisions all route through the same helper, so every
  execution path fails closed consistently.
- runToolWithHooks tracks mutation application explicitly instead of
  comparing finalPayload.toolInput by reference to the pre-coercion
  arguments. Tools that distinguish "no args" from "{}" no longer see
  `{}` when arguments were null/undefined and no handler mutated them.

Tests: +5 new cases (2 abort-propagation, 1 parse_error, 2 mutation
detection). Full suite 404 pass, lint clean, build clean.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed, no issues found

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants