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
61 changes: 59 additions & 2 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ import {
import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
import { HandlerRegistry } from '@/events';
import * as events from '@/utils/events';
import { HookRegistry } from '@/hooks';

function createResultAlteringRegistry(): HookRegistry {
const registry = new HookRegistry();
registry.register('PreToolUse', { hooks: [async () => ({})] });
return registry;
}

function createGraph(overrides: Partial<StandardGraph> = {}): StandardGraph {
const runSteps = new Map<string, t.RunStep>();
Expand Down Expand Up @@ -3223,7 +3230,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {

it('does not prestart when batch-sensitive hooks are configured', async () => {
const graph = createGraph({
hookRegistry: {} as StandardGraph['hookRegistry'],
hookRegistry: createResultAlteringRegistry(),
});
const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');

Expand Down Expand Up @@ -3253,6 +3260,56 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
expect(graph.eagerEventToolExecutions.size).toBe(0);
});

it('prestarts when the registry only has observation hooks (PostToolBatch)', async () => {
const observationRegistry = new HookRegistry();
observationRegistry.register('PostToolBatch', {
hooks: [async () => ({})],
});
const graph = createGraph({ hookRegistry: observationRegistry });
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
const batch = data as t.ToolExecuteBatchRequest;
toolExecuteCalls.push(batch);
batch.resolve([
{
toolCallId: 'call_weather',
status: 'success',
content: 'sunny',
},
]);
});

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{
id: 'call_weather',
name: 'weather',
args: { city: 'NYC' },
},
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

expect(toolExecuteCalls).toHaveLength(1);
expect(graph.eagerEventToolExecutions.get('call_weather')).toMatchObject({
toolCallId: 'call_weather',
toolName: 'weather',
});
});

it('does not buffer streamed chunks when eager execution is disabled', async () => {
const graph = createGraph({
eagerEventToolExecution: { enabled: false },
Expand Down Expand Up @@ -4418,7 +4475,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {

it('does not prestart on-arrival sealed calls when batch-sensitive hooks are configured', async () => {
const graph = createGraph({
hookRegistry: {} as StandardGraph['hookRegistry'],
hookRegistry: createResultAlteringRegistry(),
});
const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');

Expand Down
45 changes: 45 additions & 0 deletions src/hooks/HookRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import type { HookEvent, HookMatcher } from './types';
*/
type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;

/**
* Events whose hooks can change a tool call's input or output. Presence of
* any of these disables eager tool execution and early completion emission;
* observation-only events (`PostToolBatch`, `Stop`, telemetry hooks) do not.
*/
const RESULT_ALTERING_HOOK_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
] as const satisfies readonly HookEvent[];

/**
* Snapshot of a halt request raised by a hook returning
* `preventContinuation: true`. The SDK's run loop polls for this between
Expand Down Expand Up @@ -196,6 +207,31 @@ export class HookRegistry {
this.haltSignals.delete(sessionId);
}

/**
* True when any registered hook can alter a tool result before or after
* execution (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`). Eager
* tool execution and early completion emission gate on this instead of
* registry presence, so observation-only registries (e.g. a
* `PostToolBatch` steering drain) keep those fast paths. With
* `sessionId`, checks global + that session; without it, conservatively
* scans every session bucket.
*/
hasResultAlteringHooks(sessionId?: string): boolean {
if (hasResultAlteringInBucket(this.global)) {
return true;
}
if (sessionId !== undefined) {
const bucket = this.sessions.get(sessionId);
return bucket !== undefined && hasResultAlteringInBucket(bucket);
}
for (const bucket of this.sessions.values()) {
if (hasResultAlteringInBucket(bucket)) {
return true;
}
}
return false;
}

/** True if at least one matcher exists for `event` (global + session). */
hasHookFor(event: HookEvent, sessionId?: string): boolean {
if (readList(this.global, event).length > 0) {
Expand Down Expand Up @@ -242,6 +278,15 @@ function readList(
return bucket[event] ?? [];
}

function hasResultAlteringInBucket(bucket: MatcherBucket): boolean {
for (const event of RESULT_ALTERING_HOOK_EVENTS) {
if (readList(bucket, event).length > 0) {
return true;
}
}
return false;
}

function removeFromList<E extends HookEvent>(
list: HookMatcher<HookEvent>[],
matcher: HookMatcher<E>
Expand Down
48 changes: 48 additions & 0 deletions src/hooks/__tests__/HookRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,52 @@ describe('HookRegistry', () => {
}
});
});

describe('hasResultAlteringHooks', () => {
it('returns false for an empty registry', () => {
const registry = new HookRegistry();
expect(registry.hasResultAlteringHooks()).toBe(false);
expect(registry.hasResultAlteringHooks('run-1')).toBe(false);
});

it('returns false when only observation hooks are registered', () => {
const registry = new HookRegistry();
registry.register('PostToolBatch', { hooks: [async () => ({})] });
registry.register('Stop', { hooks: [async () => ({})] });
expect(registry.hasResultAlteringHooks()).toBe(false);
expect(registry.hasResultAlteringHooks('run-1')).toBe(false);
});

it('returns true for each result-altering event registered globally', () => {
for (const event of [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
] as const) {
const registry = new HookRegistry();
registry.register(event, { hooks: [async () => ({})] });
expect(registry.hasResultAlteringHooks()).toBe(true);
expect(registry.hasResultAlteringHooks('any-session')).toBe(true);
}
});

it('scopes session lookups to the given sessionId', () => {
const registry = new HookRegistry();
registry.registerSession('run-a', 'PreToolUse', makePreToolUseMatcher());
expect(registry.hasResultAlteringHooks('run-a')).toBe(true);
expect(registry.hasResultAlteringHooks('run-b')).toBe(false);
});

it('conservatively scans all sessions when no sessionId is given', () => {
const registry = new HookRegistry();
registry.registerSession(
'run-a',
'PostToolUse',
makePostToolUseMatcher()
);
expect(registry.hasResultAlteringHooks()).toBe(true);
registry.clearSession('run-a');
expect(registry.hasResultAlteringHooks()).toBe(false);
});
});
});
87 changes: 85 additions & 2 deletions src/hooks/__tests__/executeHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ describe('executeHooks', () => {
input: preToolUseInput('Bash'),
matchQuery: 'Bash',
});
expect(result).toEqual({ additionalContexts: [], errors: [] });
expect(result).toEqual({
additionalContexts: [],
injectedMessages: [],
errors: [],
});
});

it('returns an empty result when no matcher pattern matches the query', async () => {
Expand All @@ -176,7 +180,11 @@ describe('executeHooks', () => {
matchQuery: 'Bash',
});
expect(called).toBe(false);
expect(result).toEqual({ additionalContexts: [], errors: [] });
expect(result).toEqual({
additionalContexts: [],
injectedMessages: [],
errors: [],
});
});
});

Expand Down Expand Up @@ -439,6 +447,81 @@ describe('executeHooks', () => {
});
});

describe('injectedMessages accumulation', () => {
it('accumulates injectedMessages from every hook in registration order', async () => {
const registry = new HookRegistry();
registry.register('PreToolUse', {
hooks: [
preToolHook(
async (): Promise<PreToolUseHookOutput> => ({
injectedMessages: [
{ role: 'user', content: 'steer one', source: 'steer' },
],
})
),
preToolHook(
async (): Promise<PreToolUseHookOutput> => emptyPreOutput
),
],
});
registry.register('PreToolUse', {
hooks: [
preToolHook(
async (): Promise<PreToolUseHookOutput> => ({
injectedMessages: [
{ role: 'user', content: 'steer two', source: 'steer' },
{ role: 'system', content: 'hint', source: 'hook' },
],
})
),
],
});
const result = await executeHooks({
registry,
input: preToolUseInput('Bash'),
matchQuery: 'Bash',
});
expect(result.injectedMessages).toEqual([
{ role: 'user', content: 'steer one', source: 'steer' },
{ role: 'user', content: 'steer two', source: 'steer' },
{ role: 'system', content: 'hint', source: 'hook' },
]);
});

it('returns an empty array when no hook sets injectedMessages', async () => {
const registry = new HookRegistry();
registry.register('PreToolUse', { hooks: [noopPreHook] });
const result = await executeHooks({
registry,
input: preToolUseInput('Bash'),
matchQuery: 'Bash',
});
expect(result.injectedMessages).toEqual([]);
});

it('skips injectedMessages from fire-and-forget (async) outputs', async () => {
const registry = new HookRegistry();
registry.register('PreToolUse', {
hooks: [
preToolHook(
async (): Promise<PreToolUseHookOutput> => ({
async: true,
injectedMessages: [
{ role: 'user', content: 'ignored', source: 'steer' },
],
})
),
],
});
const result = await executeHooks({
registry,
input: preToolUseInput('Bash'),
matchQuery: 'Bash',
});
expect(result.injectedMessages).toEqual([]);
});
});

describe('updatedInput handling', () => {
it('last-writer-wins on updatedInput follows registration order', async () => {
const registry = new HookRegistry();
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/executeHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface AbortRace {
function freshResult(): AggregatedHookResult {
return {
additionalContexts: [],
injectedMessages: [],
errors: [],
};
}
Expand Down Expand Up @@ -244,6 +245,19 @@ function applyContext(agg: AggregatedHookResult, output: HookOutput): void {
}
}

function applyInjectedMessages(
agg: AggregatedHookResult,
output: HookOutput
): void {
if (
output.injectedMessages === undefined ||
output.injectedMessages.length === 0
) {
return;
}
agg.injectedMessages.push(...output.injectedMessages);
}

function applyStopFlag(agg: AggregatedHookResult, output: HookOutput): void {
if (output.preventContinuation !== true) {
return;
Expand Down Expand Up @@ -310,6 +324,7 @@ function fold(outcomes: readonly HookOutcome[]): AggregatedHookResult {
continue;
}
applyContext(agg, output);
applyInjectedMessages(agg, output);
applyStopFlag(agg, output);
applyDecision(agg, output);
applyUpdatedInput(agg, output);
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
export { HookRegistry } from './HookRegistry';
export type { HookHaltSignal } from './HookRegistry';
export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
/**
* Feature probe for hosts: hook outputs support `injectedMessages`
* (per-message graph-state injection at the `PostToolBatch` boundary).
* Hosts must gate drain-style hooks on this so a queued message can never
* be consumed by an SDK version that would silently drop it.
*/
export const HOOK_INJECTED_MESSAGES_CAPABLE = true;
export {
matchesQuery,
hasNestedQuantifier,
Expand Down
Loading
Loading