Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/hooks-lifecycle-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@openrouter/agent': minor
---

Add a typed lifecycle hook system to `callModel`, inspired by the Claude Agent SDK hooks pattern.

Two usage modes: an inline config object (built-in hooks only) or a `HooksManager` instance (custom hooks, dynamic registration via `on()`/`off()`/`removeAll()`, programmatic `emit()`).

Eight built-in hooks: `PreToolUse` (block or mutate tool input before every client-tool execution), `PostToolUse` / `PostToolUseFailure` (observe results and errors with timing), `UserPromptSubmit` (mutate or reject the prompt before the initial request), `PermissionRequest` (programmatically allow/deny/ask for tools requiring approval), `Stop` (force-resume a halted loop or inject a follow-up prompt, capped against runaway handlers), and `SessionStart` / `SessionEnd` (paired once per run on every exit path, including approval pauses, interruptions, errors, and no-tools streaming paths).

Features: tool matchers (string / RegExp / predicate), payload filter predicates, sequential mutation piping, short-circuit on block/reject, async fire-and-forget handlers with `drain()` / `abortInflight()` / per-handler timeouts and cooperative cancellation via `ctx.signal`, configurable error handling (`throwOnHandlerError`), and custom hook definitions via Zod schema pairs with full TypeScript inference (transforms/defaults are honored — handlers receive parsed output values).

The API is additive: existing `onTurnStart`, `onTurnEnd`, and `requireApproval` are unchanged. Public exports: `HooksManager`, `HookName`, `isAsyncOutput`, and the payload/result/config types; also available via the `@openrouter/agent/hooks-manager` subpath.
170 changes: 170 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,175 @@ const result = callModel(client, {
});
```

### Lifecycle Hooks

Observe and control the agent loop with typed lifecycle hooks — inspect or
block tool calls, mutate inputs, gate approvals programmatically, intercept
prompts, and run audit/telemetry work. Inspired by the Claude Agent SDK hooks
pattern.

> [!NOTE]
> Lifecycle hooks are distinct from the SDK **transport** hooks
> (`SDKHooks`, `BeforeRequestHook`, `HookContext`, …), which intercept
> HTTP requests. Lifecycle hooks fire on agent-loop events.

**Two usage modes.** Pass a plain object for quick setup, or a `HooksManager`
instance for custom hooks, dynamic registration, and programmatic emit:

```typescript
// Inline config — built-in hooks only
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'Clean up the temp directory',
tools: [shellTool] as const,
hooks: {
PreToolUse: [
{
matcher: 'run_shell', // string | RegExp | (toolName) => boolean
handler: ({ toolName, toolInput }) => {
if (String(toolInput.command).includes('rm -rf /')) {
return { block: 'Refusing to run a destructive command' };
}
},
},
],
PostToolUse: [
{ handler: ({ toolName, durationMs }) => console.log(toolName, durationMs) },
],
},
});
```

```typescript
// HooksManager — full control
import { HooksManager } from '@openrouter/agent';

const hooks = new HooksManager();

const unsubscribe = hooks.on('PreToolUse', {
matcher: /^db_/,
filter: (payload) => payload.sessionId !== '', // optional predicate on the payload
handler: ({ toolInput }) => ({
// Replace the tool's input before execution (mutation piping)
mutatedInput: { ...toolInput, dryRun: true },
}),
});

const result = callModel(client, { model, input, tools, hooks });

// Later: unsubscribe(), hooks.off(...), hooks.removeAll(...)
```

**Built-in hooks**

| Hook | Fires | Result fields |
|---|---|---|
| `PreToolUse` | Before every client-tool execution (auto, approval-resume, and hook-allowed paths) | `mutatedInput` replaces the tool's arguments; `block: true \| string` skips execution and reports the reason as the tool's error output |
| `PostToolUse` | After a successful tool execution (payload includes `toolOutput`, `durationMs`) | none (void) |
| `PostToolUseFailure` | After a tool execution throws or returns an error. Not fired when a tool never ran (`PermissionRequest` deny, user rejection, `PreToolUse` block) — observe those via the gating hooks themselves | none (void) |
| `UserPromptSubmit` | Before the initial API request, with the user prompt string | `mutatedPrompt` replaces the prompt; `reject: true \| string` aborts the call with an error |
| `PermissionRequest` | When a tool requires approval, before pausing for the human gate | `decision: 'allow'` skips the gate (the tool runs once via the normal round), `'deny'` synthesizes a rejected result without executing, `'ask_user'` (default) falls through to the approval flow. Last handler wins. Payload includes a `riskLevel` derived from the approval gate's shape (`'high'` for tool- or call-level functions, `'medium'` for blanket `true`) |
| `Stop` | When a `stopWhen` condition halts the loop (`reason: 'max_turns'`) | `forceResume: true` continues the loop (capped at 3 consecutive overrides without tool progress — a bare `forceResume` that changes no state will typically re-trigger the stop condition immediately and burn through the cap, so pair it with `appendPrompt` or external state the stop condition observes); `appendPrompt` injects a user message for the next turn (honored independently of `forceResume`). Blocked/rejected tool outputs count as progress for the cap: the model receives that feedback, and each round costs a full request, so the loop cannot spin hot |
| `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) |
| `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user'` | none (void) |

Notes on lifecycle pairing: `SessionEnd` only fires when a matching
`SessionStart` succeeded, and at most once per run. Pending async hook work is
always drained on teardown — including on paths that skip `SessionStart`,
such as resuming from a tool approval. A throwing `SessionEnd` handler never
masks the run's original error (teardown failures are logged as warnings).

**Handler chain semantics**

Handlers for a hook run sequentially in registration order.

- **Matchers** (`matcher`) scope a handler to tool names — exact string,
`RegExp` (stateful `/g`//`/y` flags are handled safely), or a predicate
function (truthy/falsy returns are coerced to boolean). Matchers fail
closed: a matcher-scoped handler is skipped when the emit has no tool name.
- **Filters** (`filter`) are arbitrary predicates on the payload.
- **Mutation piping**: a handler's `mutatedInput`/`mutatedPrompt` replaces the
corresponding payload field for all subsequent handlers in the chain, and
for the tool/request itself. A blocking handler's mutation still lands
before the short-circuit.
- **Short-circuit**: `block`/`reject` with `true` or a non-empty string stops
the chain (empty strings do not block).
- **Error policy**: by default a throwing handler — or a throwing
matcher/filter — is logged as a warning and skipped, and the chain
continues. Construct the manager with
`new HooksManager(custom, { throwOnHandlerError: true })` to propagate
errors instead (useful in tests).

**Async fire-and-forget handlers**

A handler can detach background work (telemetry, audit writes) without
blocking the loop by returning an `AsyncOutput` signal:

```typescript
hooks.on('PostToolUse', {
handler: (payload, ctx) => ({
async: true,
work: sendTelemetry(payload, { signal: ctx.signal }),
asyncTimeout: 5_000, // default 30_000
}),
});

// On shutdown: abort in-flight handlers, then wait for detached work
hooks.abortInflight('shutdown');
await hooks.drain();
```

`drain()` waits for all detached work, bounded per-handler by `asyncTimeout`.
On timeout the emit's `ctx.signal` is aborted and a warning is logged — the
work itself cannot be forcibly cancelled, so handlers should observe
`ctx.signal` to stop cooperatively. `abortInflight()` reaches detached work
even after the originating `emit()` has returned. The signal object must have
**exactly** the `AsyncOutput` shape (`async: true` plus optional `work`/
`asyncTimeout`); a return value carrying any other field is treated as a
regular result so mutations/blocks are never silently discarded. The
`isAsyncOutput` type guard is exported.

**Custom hooks**

Define your own hooks with Zod schema pairs and full type inference, then emit
them from your own code:

```typescript
import { HooksManager } from '@openrouter/agent';
import { z } from 'zod/v4';

const hooks = new HooksManager({
DeploymentGate: {
payload: z.object({ environment: z.string(), version: z.string() }),
result: z.object({ approved: z.boolean() }),
},
AuditLog: {
payload: z.object({ event: z.string() }),
result: z.void(), // side-effect only: results are not validated
},
});

hooks.on('DeploymentGate', {
handler: ({ environment }) => ({ approved: environment !== 'production' }),
});

const { results } = await hooks.emit('DeploymentGate', {
environment: 'staging',
version: '1.2.3',
});
```

Payloads and results are validated against the schemas on every `emit`.
Schemas with `.transform()`, `.default()`, or `.coerce` are honored: handlers
receive the parsed **output** values, matching the inferred TypeScript types.
Custom hook names must be non-empty and must not collide with built-in names.
Custom hooks do not participate in mutation piping or blocking (those are
built-in-only behaviors); the inline config surface only accepts built-in
hooks — unknown names are warned about and skipped.

A payload validation failure follows the same error policy as handlers:
logged and skipped by default, thrown in strict mode.

### Tool Context

Provide typed context data to tools without passing it through the model:
Expand Down Expand Up @@ -366,6 +535,7 @@ For tree-shaking or targeted imports, the package provides granular subpath expo
import { callModel } from '@openrouter/agent/call-model';
import { tool } from '@openrouter/agent/tool';
import { ModelResult } from '@openrouter/agent/model-result';
import { HooksManager } from '@openrouter/agent/hooks-manager';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import { toClaudeMessage } from '@openrouter/agent/anthropic-compat';
import { toChatMessage } from '@openrouter/agent/chat-compat';
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"types": "./esm/lib/model-result.d.ts",
"default": "./esm/lib/model-result.js"
},
"./hooks-manager": {
"types": "./esm/lib/hooks-manager.d.ts",
"default": "./esm/lib/hooks-manager.js"
},
"./async-params": {
"types": "./esm/lib/async-params.d.ts",
"default": "./esm/lib/async-params.js"
Expand Down
34 changes: 34 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ export {
toolRequiresApproval,
updateState,
} from './lib/conversation-state.js';
// Lifecycle hooks system (PreToolUse, PostToolUse, Stop, SessionStart, ...).
// Distinct from the SDK transport hooks re-exported above (SDKHooks,
// BeforeRequestHook, HookContext, ...), which intercept HTTP requests.
// Internals (matchesTool, resolveHooks, BUILT_IN_HOOKS, raw Zod schemas) are
// deliberately NOT exported: everything here is semver surface, and the raw
// schema objects are mutable.
export { HooksManager } from './lib/hooks-manager.js';
export type {
AsyncOutput,
BuiltInHookDefinitions,
EmitResult,
HookDefinition,
HookEntry,
HookHandler,
HookRegistry,
HookReturn,
HooksManagerOptions,
InlineHookConfig,
LifecycleHookContext,
PermissionRequestPayload,
PermissionRequestResult,
PostToolUseFailurePayload,
PostToolUsePayload,
PreToolUsePayload,
PreToolUseResult,
SessionEndPayload,
SessionStartPayload,
StopPayload,
StopResult,
ToolMatcher,
UserPromptSubmitPayload,
UserPromptSubmitResult,
} from './lib/hooks-types.js';
export { HookName, isAsyncOutput } from './lib/hooks-types.js';
export type { GetResponseOptions } from './lib/model-result.js';
export { ModelResult } from './lib/model-result.js';
// Next turn params helpers
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/inner-loop/call-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core';
import type { RequestOptions } from '@openrouter/sdk/lib/sdks';
import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core';
import type { CallModelInput } from '../lib/async-params.js';
import { resolveHooks } from '../lib/hooks-resolve.js';
import type { GetResponseOptions } from '../lib/model-result.js';
import { ModelResult } from '../lib/model-result.js';
import { convertToolsToAPIFormat } from '../lib/tool-executor.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ export function callModel<
onTurnEnd,
allowFinalResponse,
strictFinalResponse,
hooks,
...apiRequest
} = request;

Expand Down Expand Up @@ -169,5 +171,8 @@ export function callModel<
...(strictFinalResponse !== undefined && {
strictFinalResponse,
}),
...(hooks !== undefined && {
hooks: resolveHooks(hooks),
}),
} as GetResponseOptions<TTools, TShared>);
}
5 changes: 5 additions & 0 deletions packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type * as models from '@openrouter/sdk/models';
import type { OpenResponsesResult } from '@openrouter/sdk/models';
import type { HooksManager } from './hooks-manager.js';
import type { InlineHookConfig } from './hooks-types.js';
import type { Item } from './item-types.js';
import type { ContextInput } from './tool-context.js';
import type {
Expand Down Expand Up @@ -104,6 +106,8 @@ type BaseCallModelInput<
* finals after tool work are retried once, then accepted with empty text.
*/
strictFinalResponse?: boolean;
/** Hook system for lifecycle events. Accepts inline config or a HooksManager instance. */
hooks?: InlineHookConfig | HooksManager;
};

/**
Expand Down Expand Up @@ -206,6 +210,7 @@ export async function resolveAsyncFunctions<TTools extends readonly Tool[] = rea
'onTurnEnd', // Client-side turn end callback
'allowFinalResponse', // Client-side: triggers no-tools final turn when stopWhen breaks the loop
'strictFinalResponse', // Client-side: restore throw on empty final after tool rounds
'hooks', // Client-side hook system
]);

// Iterate over all keys in the input
Expand Down
Loading
Loading