Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/direct-event-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Return `updatedInput` and `additionalContext` directly from `events` handlers without starting a render worker. (#873)
39 changes: 30 additions & 9 deletions packages/agent-bundle/src/events/handler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { settleBeforeAbort } from '../core/abort.ts';
import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts';
import { isJsonRecord, isRecord, snapshotStrictJsonValue, type JsonObject, type JsonValue } from '../core/strict-json.ts';
import { eventContracts, type CanonicalAgentEvent } from '../routes/events.ts';
import type { AgentEventCanonicalIdentity } from '../routes/public.ts';
import type { AgentTerminal } from '../terminal-capability.ts';
import type { EventTracer } from './trace.ts';

export type EventHandlerResult<Data extends JsonValue = JsonValue> =
export type EventHandlerResult<
Data extends JsonValue = JsonValue,
Event extends CanonicalAgentEvent = CanonicalAgentEvent,
> =
| { readonly outcome: 'render'; readonly module: string; readonly data: Data }
| { readonly outcome: 'continue' }
| { readonly outcome: 'deny'; readonly reason: string };
| ({ readonly outcome: 'continue'; readonly additionalContext?: string } &
(Event extends 'tool/before' ? { readonly updatedInput?: JsonObject } : { readonly updatedInput?: never }))
| { readonly outcome: 'deny'; readonly reason: string; readonly additionalContext?: string };

export interface EventHandlerContext<E extends CanonicalAgentEvent = CanonicalAgentEvent> {
readonly render: (module: string, data: JsonValue) => Extract<EventHandlerResult, { outcome: 'render' }>;
Expand Down Expand Up @@ -43,6 +47,14 @@ const unexpectedFields = (record: Readonly<Record<string, unknown>>, allowed: Re
}
};

const handlerContext = (value: Readonly<Record<string, unknown>>): { readonly additionalContext?: string } => {
if (value.additionalContext === undefined) return {};
if (typeof value.additionalContext !== 'string') {
throw new TypeError('Event handler additionalContext must be a string.');
}
return { additionalContext: value.additionalContext };
};

/**
* Validates a runtime handler return into {@link EventHandlerResult}.
* Unknown outcomes, extra fields, an empty denial reason, and deny on a
Expand All @@ -66,18 +78,27 @@ export const validateEventHandlerResult = (
unexpectedFields(value, new Set(['outcome', 'module', 'data']));
if (view === undefined || value.module !== view) throw new TypeError('ctx.render() must name the event handler’s sibling .view.js module.');
return Object.freeze({ data: snapshotStrictJsonValue(value.data), module: view, outcome: 'render' });
case 'continue':
unexpectedFields(value, new Set(['outcome']));
return Object.freeze({ outcome: 'continue' });
case 'continue': {
unexpectedFields(value, new Set(['outcome', 'additionalContext', ...(event === 'tool/before' ? ['updatedInput'] : [])]));
const updatedInput = value.updatedInput === undefined ? undefined : snapshotStrictJsonValue(value.updatedInput);
if (updatedInput !== undefined && !isJsonRecord(updatedInput)) {
throw new TypeError('Event handler updatedInput must be an object on tool/before.');
}
return Object.freeze({
outcome: 'continue',
...(updatedInput === undefined ? {} : { updatedInput }),
...handlerContext(value),
});
}
case 'deny': {
unexpectedFields(value, new Set(['outcome', 'reason']));
unexpectedFields(value, new Set(['outcome', 'reason', 'additionalContext']));
if (!eventContracts[event].deny) {
throw new TypeError(`${event} cannot deny from handler.`);
}
if (typeof value.reason !== 'string' || value.reason.trim() === '') {
throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`);
}
return Object.freeze({ outcome: 'deny', reason: value.reason });
return Object.freeze({ outcome: 'deny', reason: value.reason, ...handlerContext(value) });
}
default: {
const exhaustive: never = outcome;
Expand Down
31 changes: 19 additions & 12 deletions packages/agent-bundle/src/events/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,15 +1137,22 @@ export const projectEventHandlerResult = (
target: string,
nativeEvent: string,
nativeInput?: Readonly<Record<string, unknown>>,
): Readonly<Record<string, unknown>> | undefined => projectEventDocument(
{
root: { children: [], kind: 'result' },
status: 'success',
value: result,
version: 1,
},
event,
target,
nativeEvent,
nativeInput,
);
): Readonly<Record<string, unknown>> | undefined => {
const { additionalContext } = result;
return projectEventDocument(
{
root: { children: additionalContext === undefined ? [] : [{ kind: 'context', text: additionalContext }], kind: 'result' },
status: 'success',
value: result.outcome === 'deny'
? { outcome: result.outcome, reason: result.reason }
: result.updatedInput === undefined
? { outcome: result.outcome }
: { outcome: result.outcome, updatedInput: result.updatedInput },
version: 1,
},
event,
target,
nativeEvent,
nativeInput,
);
};
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/routes/event-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type { JsonValue } from '../core/strict-json.ts';
export type EventResult<Event extends CanonicalAgentEvent = CanonicalAgentEvent> =
| Extract<EventHandlerResult, { outcome: 'render' }>
| void
| { readonly outcome: 'continue' }
| (Event extends DenyingEvent ? { readonly outcome: 'deny'; readonly reason: string } : never);
| Extract<EventHandlerResult<JsonValue, Event>, { outcome: 'continue' }>
| (Event extends DenyingEvent ? Extract<EventHandlerResult, { outcome: 'deny' }> : never);

export type EventContext<Event extends CanonicalAgentEvent = CanonicalAgentEvent> = AgentEventRouteProps<Event> & Pick<AgentRequestContext, 'provider' | 'process'> & {
readonly render: (module: string, data: JsonValue) => Extract<EventHandlerResult, { outcome: 'render' }>;
Expand Down
18 changes: 17 additions & 1 deletion packages/agent-bundle/tests/event-handler-artifact-graph.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rename, symlink, writeFile } from 'node:fs/promises';
import { isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
Expand Down Expand Up @@ -64,6 +64,7 @@ const projectFiles: Readonly<Record<string, string>> = {
'export default ({ canonical, render }) => {',
" const tool = canonical.payload?.['toolInput'] as { readonly value?: { readonly command?: unknown } } | undefined;",
" const command = typeof tool?.value?.command === 'string' ? tool.value.command : '';",
" if (command === 'rewrite') return { outcome: 'continue', updatedInput: { command: 'cargo check --locked' }, additionalContext: 'Checking.' };",
" if (mentionsCargo(command)) return render('./before.view.js', { ticket: 'cc-7' });",
" return command === 'blocked' ? { outcome: 'deny', reason: HANDLER_LEAF_SENTINEL } : { outcome: 'continue' };",
'};',
Expand Down Expand Up @@ -298,6 +299,21 @@ describe('handler artifact graph (#595)', () => {
});
});

it('returns rewrites and context even when the deferred executor is unavailable', async () => {
const executor = join(artifactRoot, entryPath.replace(/\.mjs$/u, '.execute.mjs'));
await rename(executor, `${executor}.disabled`);
try {
const rewritten = await invoke('rewrite');
expect(rewritten.code).toBe(0);
expect(rewritten.stderr).toBe('');
expect(JSON.parse(rewritten.stdout)).toEqual({ hookSpecificOutput: {
hookEventName: 'PreToolUse', updatedInput: { command: 'cargo check --locked' }, additionalContext: 'Checking.',
} });
} finally {
await rename(`${executor}.disabled`, executor);
}
});

it('passes computed handler data to the deferred route', async () => {
const executed = await invoke('cargo check');
expect(executed.code).toBe(0);
Expand Down
53 changes: 52 additions & 1 deletion packages/agent-bundle/tests/event-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
validateEventHandlerResult,
type EventHandlerContext,
} from '../src/events/handler.ts';
import { projectEventHandlerResult } from '../src/events/projection.ts';
import { projectEventDocument, projectEventHandlerResult } from '../src/events/projection.ts';
import { eventContracts } from '../src/routes/events.ts';
import { canonicalAgentEvents, type CanonicalAgentEvent } from '../src/routes/public.ts';

Expand Down Expand Up @@ -165,3 +165,54 @@ it('projects a gate decision through the rendered event outcome rules', () => {
},
});
});


it('snapshots strict JSON rewrites and rejects invalid direct-result fields', () => {
const updatedInput = { command: 'cargo check --locked', nested: { args: ['--locked'] } };
const result = validateEventHandlerResult({ outcome: 'continue', updatedInput, additionalContext: 'Checking.' }, 'tool/before');
updatedInput.nested.args.push('--release');
expect(result).toEqual({ outcome: 'continue', updatedInput: { command: 'cargo check --locked', nested: { args: ['--locked'] } }, additionalContext: 'Checking.' });
if (result.outcome !== 'continue') throw new Error('Expected continue.');
expect(Object.isFrozen(result.updatedInput)).toBe(true);
for (const invalid of [[], null, 'command', new Date(), { command: undefined }, { command: NaN }]) {
expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput: invalid }, 'tool/before')).toThrow();
}
for (const updatedInput of [{}, undefined]) {
expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput }, 'tool/after')).toThrow(/unsupported field/u);
}
expect(() => validateEventHandlerResult({ outcome: 'continue', additionalContext: {} }, 'tool/after')).toThrow(/must be a string/u);
expect(() => validateEventHandlerResult({ outcome: 'deny', reason: 'blocked', additionalContext: 1 }, 'tool/before')).toThrow(/must be a string/u);
});

it('projects direct rewrites and context exactly like rendered results on each host', () => {
for (const [event, nativeEvent] of [['tool/before', 'PreToolUse'], ['tool/after', 'PostToolUse']] as const) {
const result = validateEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.', ...(event === 'tool/before' ? { updatedInput: { command: 'cargo check --locked' } } : {}) }, event);
if (result.outcome === 'render') throw new Error('Unexpected render.');
for (const target of ['claude', 'codex', 'cursor']) {
const rendered = projectEventDocument({
root: { kind: 'result', children: [{ kind: 'context', text: 'Notice.' }] },
value: event === 'tool/before'
? { outcome: 'continue', updatedInput: { command: 'cargo check --locked' } }
: { outcome: 'continue' },
status: 'success',
version: 1,
}, event, target, nativeEvent);
expect(projectEventHandlerResult(result, event, target, nativeEvent)).toEqual(rendered);
}
}
expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'cursor', 'preToolUse'))
.toEqual({ permission: 'allow', updated_input: { command: 'cargo check' } });
expect(projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'tool/after', 'claude', 'PostToolUse'))
.toEqual({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'Notice.' } });
const denial = validateEventHandlerResult({ outcome: 'deny', reason: 'Blocked.', additionalContext: 'Notice.' }, 'tool/before');
if (denial.outcome !== 'deny') throw new Error('Expected denial.');
for (const target of ['claude', 'codex']) {
expect(projectEventHandlerResult(denial, 'tool/before', target, 'PreToolUse')).toEqual({ hookSpecificOutput: {
hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Blocked.', additionalContext: 'Notice.',
} });
}
expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'amp', 'tool.call'))
.toEqual({ action: 'modify', input: { command: 'cargo check' } });
expect(() => projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'agent/start', 'cursor', 'subagentStart'))
.toThrow(/no additional-context channel/u);
});
12 changes: 11 additions & 1 deletion packages/agent-bundle/tests/function-authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ import { validateRouteModuleContract } from '../src/routes/contract.ts';
import { defineTool, normalizeRouteModule } from '../src/routes/definitions.ts';
import { parseModule } from '../src/routes/syntax.ts';
import { readRouteDefinition } from '../src/routes/definition-syntax.ts';
import { events } from '../src/routes/event-definitions.ts';
import { events, type EventResult } from '../src/routes/event-definitions.ts';
import { executeEventHandler } from '../src/events/handler.ts';
import { createCanonicalEventProps, projectEventHandlerResult } from '../src/events/projection.ts';

const path = '/project/src/mcp/runtime/tools/status.tsx';

it('types direct event results according to the event family and outcome', () => {
const rewrite = { outcome: 'continue', updatedInput: { command: 'cargo check' }, additionalContext: 'Checking.' } satisfies EventResult<'tool/before'>;
const context = { outcome: 'continue', additionalContext: 'Finished.' } satisfies EventResult<'tool/after'>;
// @ts-expect-error Only tool/before can rewrite pending input.
const afterRewrite: EventResult<'tool/after'> = { outcome: 'continue', updatedInput: {} };
// @ts-expect-error A denial cannot also rewrite input.
const deniedRewrite: EventResult<'tool/before'> = { outcome: 'deny', reason: 'Blocked.', updatedInput: {} };
expect([rewrite, context, afterRewrite, deniedRewrite]).toHaveLength(4);
});
const source = `
import { defineTool as tool } from 'agent-bundle/routes';
import { z } from 'zod';
Expand Down
17 changes: 17 additions & 0 deletions website/docs/en/guide/authoring/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ The helper infers the canonical payload and family-specific outcomes; its event
the file path. A plain default function with the same context and result is also supported.
Use `EventHandler<'tool/before'>` when annotating that function.

Return `updatedInput` directly on a continuing `tool/before` handler to rewrite the pending
input. Return `additionalContext` on a continuing or denying handler for a short context
message. These results use the same host projection as JSX and start no render worker;
`updatedInput` must be a strict JSON object and cannot accompany a denial. Context channels
remain host-specific: Cursor drops context on `tool/before` but accepts it on `tool/after`;
Claude and Codex accept context on both. Use `requires` or `targets` for other event families
whose hosts differ. A rewrite preserves the host permission flow on Claude and Codex; Cursor
requires `permission: 'allow'` alongside `updated_input`.

```ts
export default events.tool.before({}, () => ({
outcome: 'continue',
updatedInput: { command: 'cargo check --locked' },
additionalContext: 'Checking with the lockfile.',
}));
```

For a cheap gate followed by JSX, put the view in a separate sibling module:

```ts
Expand Down
16 changes: 16 additions & 0 deletions website/docs/zh/guide/authoring/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ export default events.tool.before({}, async ({ canonical, provider }) => {
辅助函数推断该事件的载荷与允许的结果,事件名称必须与文件路径一致。也可使用普通默认函数,并用
`EventHandler<'tool/before'>` 标注类型。

在 `tool/before` 处理器的继续结果中直接返回 `updatedInput`,即可改写待执行的输入。
继续或拒绝结果可以带 `additionalContext`,用于简短的上下文消息。这些结果与 JSX 使用相同的
宿主投影,不启动渲染工作进程。`updatedInput` 必须是严格 JSON 对象,且不能与拒绝结果一起返回。
上下文通道仍取决于宿主:Cursor 忽略 `tool/before` 的上下文,但接受 `tool/after` 的上下文;
Claude 和 Codex 在两者上都接受上下文。其他存在宿主差异的事件可使用 `requires` 或 `targets`。
改写在 Claude 和 Codex 上保留宿主权限流程;Cursor 要求 `updated_input` 同时携带
`permission: 'allow'`。

```ts
export default events.tool.before({}, () => ({
outcome: 'continue',
updatedInput: { command: 'cargo check --locked' },
additionalContext: 'Checking with the lockfile.',
}));
```

需要先判断再渲染时,将 JSX 放入独立的同级模块:

```ts
Expand Down
Loading