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
40 changes: 40 additions & 0 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5342,6 +5342,46 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
expect(graph.eagerEventToolExecutions.has('call_cf2')).toBe(false);
});

it('does not prestart dispatcher calls', async () => {
const graph = createGraph({
eagerEventToolExecution: { enabled: true },
getAgentContext: jest.fn(() => ({
provider: Providers.ANTHROPIC,
reasoningKey: 'reasoning',
toolDefinitions: [{ name: 'call_tool' }],
toolDispatch: true,
agentId: 'agent_1',
})) as unknown as StandardGraph['getAgentContext'],
});
const toolExecute = jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockResolvedValue(undefined);

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{
id: 'call_dispatch',
name: 'call_tool',
args: { name: 'late_tool', arguments: '{"query":"x"}' },
},
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

expect(
toolExecute.mock.calls.some(([event]) => event === GraphEvents.ON_TOOL_EXECUTE)
).toBe(false);
expect(graph.eagerEventToolExecutions.size).toBe(0);
});

it('keeps the direct-tool batch guard when an excluded tool is also direct', async () => {
// edit_file is both a direct graph tool AND excluded from eager. A mixed
// batch with a direct tool must suppress eager for the whole batch, so the
Expand Down
40 changes: 36 additions & 4 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import { snapshotCompactionSemanticIndex } from '@/summarization/semanticIndex';
import { createExactTokenCountCache } from '@/llm/contextPressureMeter';
import { markProviderTextInput } from '@/protection/providerTextInput';
import { buildSummaryCarrierText } from '@/summarization/shared';
import {
createToolDispatcherDefinition,
resolveToolDispatch,
} from '@/tools/toolDispatch';
import { createSchemaOnlyTools } from '@/tools/schema';
import { apportionTokenCounts } from '@/utils/tokens';
import { isThinkingEnabled } from '@/llm/request';
Expand Down Expand Up @@ -136,6 +140,7 @@ export class AgentContext {
subagentConfigs,
maxSubagentDepth,
graphTools,
toolDispatch,
} = agentConfig;

const agentContext = new AgentContext({
Expand Down Expand Up @@ -178,6 +183,7 @@ export class AgentContext {
};
agentContext.subagentConfigs = subagentConfigs;
agentContext.maxSubagentDepth = maxSubagentDepth;
agentContext.toolDispatch = resolveToolDispatch(toolDispatch);
/**
* Host-supplied direct tools (see `AgentInputs.graphTools`). Copied — never
* aliased — because the SDK later pushes graph-managed tools (handoff /
Expand Down Expand Up @@ -374,6 +380,8 @@ export class AgentContext {
* When provided, ToolNode operates in event-driven mode.
*/
toolDefinitions?: t.LCTool[];
/** Binds the `call_tool` dispatcher (`AgentInputs.toolDispatch`). */
toolDispatch: false | t.ToolDispatchOptions = false;
/** Set of tool names discovered via tool search (to be loaded) */
discoveredToolNames: Set<string> = new Set();
/** Original AgentInputs used to create this context — used for self-spawn subagent resolution. */
Expand Down Expand Up @@ -1378,10 +1386,20 @@ export class AgentContext {
*/
return resolveCallerCapabilityProjection(
effectiveToolDefinitions,
(toolDef) => isToolDefinitionActive(toolDef, this.discoveredToolNames)
(toolDef) => this.isToolDefinitionBound(toolDef)
).directTools;
}

/**
* With a dispatcher, deferred tools are reached through it and never bound,
* even once discovered: binding them would change `tools` mid-conversation.
*/
private isToolDefinitionBound(toolDef: t.LCTool): boolean {
return this.toolDispatch !== false
? toolDef.defer_loading !== true
: isToolDefinitionActive(toolDef, this.discoveredToolNames);
}

/**
* Single source of truth for "which entries of `this.tools` should be
* treated as actually bound". Callers:
Expand Down Expand Up @@ -1467,7 +1485,9 @@ export class AgentContext {
}
}

for (const def of this.getActiveToolDefinitions()) {
const dispatcher =
this.toolDispatch === false ? [] : [createToolDispatcherDefinition()];
for (const def of [...this.getActiveToolDefinitions(), ...dispatcher]) {
if (countedToolNames.has(def.name)) {
continue;
}
Expand Down Expand Up @@ -2274,6 +2294,19 @@ export class AgentContext {
* @returns Array of tools to bind to model
*/
getToolsForBinding(): t.GraphTools | undefined {
const tools = this.getToolsWithoutDispatcher();
if (this.toolDispatch === false) {
return tools;
}
return [
...(tools ?? []),
...(createSchemaOnlyTools([
createToolDispatcherDefinition(),
]) as t.GraphTools),
];
}

private getToolsWithoutDispatcher(): t.GraphTools | undefined {
if (this.toolDefinitions && this.toolDefinitions.length > 0) {
return this.getEventDrivenToolsForBinding();
}
Expand Down Expand Up @@ -2324,8 +2357,7 @@ export class AgentContext {
}

return (
allowsToolCaller(toolDef, 'direct') &&
isToolDefinitionActive(toolDef, this.discoveredToolNames)
allowsToolCaller(toolDef, 'direct') && this.isToolDefinitionBound(toolDef)
);
});
}
Expand Down
51 changes: 51 additions & 0 deletions src/agents/__tests__/AgentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3255,4 +3255,55 @@ describe('AgentContext', () => {
expect(ctx.systemMessageTokens).toBeGreaterThan(systemBefore);
});
});

describe('toolDispatch', () => {
const boundNames = (ctx: AgentContext): string[] =>
(ctx.getToolsForBinding() ?? []).map((tool) =>
'name' in tool ? String(tool.name) : ''
);
const toolDefinitions: t.LCTool[] = [
{ name: 'bound_tool' },
{ name: 'late_tool', defer_loading: true },
];

it('binds call_tool after the other tools and leaves deferred tools unbound', () => {
const ctx = createBasicContext({
agentConfig: { toolDefinitions, toolDispatch: true },
});
expect(boundNames(ctx)).toEqual(['bound_tool', 'call_tool']);
});

it('keeps a discovered deferred tool unbound with the dispatcher, bound without it', () => {
const bound = (toolDispatch: boolean): string[] => {
const ctx = createBasicContext({
agentConfig: { toolDefinitions, toolDispatch: toolDispatch || undefined },
});
ctx.markToolsAsDiscovered(['late_tool']);
return boundNames(ctx);
};
expect(bound(true)).toEqual(['bound_tool', 'call_tool']);
expect(bound(false)).toEqual(['bound_tool', 'late_tool']);
});

it('binds no dispatcher unless the host opts in', () => {
const ctx = createBasicContext({ agentConfig: { toolDefinitions } });
expect(boundNames(ctx)).toEqual(['bound_tool']);
});

it('counts the dispatcher schema with the other tool schemas', async () => {
const tokenCounter = (message: { content: unknown }): number =>
String(message.content).includes('call_tool') ? 100 : 1;
const toolTokens = async (toolDispatch?: boolean): Promise<number> => {
const ctx = createBasicContext({
agentConfig: { toolDefinitions, toolDispatch },
});
await ctx.calculateInstructionTokens(
tokenCounter as unknown as t.TokenCounter
);
return ctx.toolSchemaTokens;
};

expect((await toolTokens(true)) - (await toolTokens())).toBeGreaterThanOrEqual(100);
});
});
});
46 changes: 46 additions & 0 deletions src/aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,3 +1212,49 @@ describe('ContentAggregator citation deltas', () => {
});
});
});

describe('ContentAggregator dispatched tool calls', () => {
it('keeps the dispatched target on the completed dispatcher call', () => {
const { contentParts, aggregateContent } = createContentAggregator();
contentParts.push({
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 'call_late',
name: 'call_tool',
args: '{"name":"late_tool","arguments":"{}"}',
},
});

aggregateContent({
event: GraphEvents.ON_RUN_STEP_COMPLETED,
data: {
result: {
id: '',
index: 0,
type: 'tool_call',
tool_call: {
id: 'call_late',
name: 'call_tool',
args: '{"name":"late_tool","arguments":"{}"}',
output: 'found',
progress: 1,
dispatched: { name: 'late_tool', args: '{}' },
} as t.ProcessedToolCall,
},
} as { result: t.ToolEndEvent },
});

expect(contentParts[0]).toEqual({
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 'call_late',
name: 'call_tool',
args: '{"name":"late_tool","arguments":"{}"}',
type: 'tool_call',
output: 'found',
progress: 1,
dispatched: { name: 'late_tool', args: '{}' },
},
});
});
});
2 changes: 2 additions & 0 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2959,6 +2959,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
toolRegistry: agentContext?.toolRegistry,
getDiscoveredToolNames: (): readonly string[] =>
agentContext?.getDiscoveredTools() ?? [],
toolDispatch: agentContext?.toolDispatch,
hookRegistry: this.hookRegistry,
humanInTheLoop: this.humanInTheLoop,
eagerEventToolExecution: this.eagerEventToolExecution,
Expand Down Expand Up @@ -3048,6 +3049,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
toolRegistry: agentContext?.toolRegistry,
getDiscoveredToolNames: (): readonly string[] =>
agentContext?.getDiscoveredTools() ?? [],
toolDispatch: agentContext?.toolDispatch,
sessions: this.sessions,
codeSessionKey: agentContext?.codeSessionKey,
toolExecution: this.toolExecution,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from './tools/subagent';
export * from './tools/ReadFile';
export * from './tools/skillCatalog';
export * from './tools/ToolSearch';
export { TOOL_DISPATCHER_NAME } from './tools/toolDispatch';
export * from './tools/ToolNode';
export * from './tools/intentArg';
export * from './tools/schema';
Expand Down
21 changes: 20 additions & 1 deletion src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
coerceRecordArgs,
normalizeError,
} from '@/tools/eagerEventExecution';
import { TOOL_DISPATCHER_NAME } from '@/tools/toolDispatch';
import {
serializeStructuredValueBounded,
serializeToolContentBounded,
Expand Down Expand Up @@ -185,6 +186,20 @@ function isEagerExecutionExcludedTool(
if (excluded != null && excluded.includes(name)) {
return true;
}
// ToolNode unwraps dispatched calls and refuses disabled tools; a prestart
// would run them before that happens.
const toolDef =
agentContext?.toolRegistry?.get(name) ??
agentContext?.toolDefinitions?.find((def) => def.name === name);
const dispatch =
agentContext?.toolDispatch != null && agentContext.toolDispatch !== false;
if (
(dispatch && name === TOOL_DISPATCHER_NAME) ||
toolDef?.disabled != null ||
(dispatch && toolDef?.defer_loading === true)
) {
return true;
}
// Run-scoped circuit breaker: once a prestart for this tool diverged from
// the final request ("changed after eager execution started"), stop
// prestarting it so the model's retry executes normally instead of
Expand Down Expand Up @@ -2634,9 +2649,13 @@ export function createContentAggregator(): t.ContentAggregatorResult {
}
}

const dispatched = finalUpdate
? (contentPart.tool_call as t.ToolCallPart).dispatched
: undefined;
contentParts[index] = {
type: ContentTypes.TOOL_CALL,
tool_call: newToolCall,
tool_call:
dispatched != null ? { ...newToolCall, dispatched } : newToolCall,
};
indexContentPart(index, contentParts[index]);
indexedContentLength = Math.max(indexedContentLength, index + 1);
Expand Down
Loading