返回源码地图

packages/coding-agent/src/core/nested-tool-calls.ts

v1.0.0 · a13d35a742c6 · 09:嵌套授权、父调用归属和结果包装

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Tool calls that a tool makes while it runs (`ctx.executeTool()`), for example from codemode
3 * scripts. The agent loop does not know about them: the session runs each one through the agent's
4 * tool pipeline (`runToolCall`) with its own hooks, emits `tool_execution_*` events with
5 * `parentToolCallId`, and records the calls and their usage on the model-issued call's tool result
6 * message.
7 *
8 * Nothing here runs until a tool calls `ctx.executeTool()`.
9 */
10
11import type {
12 AgentTool,
13 AgentToolCall,
14 AgentToolCallOutcome,
15 AgentToolResult,
16 AgentToolUpdateCallback,
17} from "@earendil-works/pi-agent-core";
18import type { JsonObject, NestedToolCallRecord, NestedToolCalls, TextContent, Usage } from "@earendil-works/pi-ai";
19import { combineUsage } from "./usage-totals.ts";
20
21/**
22 * Limits of the nested-call record on a tool result: arguments
23 * over the per-call or total size are omitted, calls beyond the count are dropped, and the record
24 * is marked incomplete when any of that happens.
25 */
26export const NESTED_CALL_LIMITS = {
27 maxCalls: 256,
28 maxArgumentBytesPerCall: 8 * 1024,
29 maxArgumentBytesTotal: 32 * 1024,
30 maxErrorChars: 500,
31} as const;
32
33const encoder = new TextEncoder();
34
35/** What the nested calls of one model-issued tool call leave on its tool result message. */
36export interface NestedCallSummary {
37 /** Becomes `nestedCalls`. Undefined when no nested call was made. */
38 calls: NestedToolCalls | undefined;
39 /** Summed `usage` of the nested results, added to the message's `usage`. */
40 usage: Usage | undefined;
41}
42
43/**
44 * Collects the nested calls of one model-issued tool call, including calls made by nested tools.
45 * The snapshot becomes `nestedCalls` on the tool result message.
46 */
47export class NestedCallRecorder {
48 private readonly calls: NestedToolCallRecord[] = [];
49 private readonly startedAt = new Map<NestedToolCallRecord, number>();
50 private complete = true;
51 private argumentBytes = 0;
52 /** Summed usage of every nested result, including calls dropped from the record. */
53 private usage: Usage | undefined;
54
55 /** Record a call as it starts. Returns undefined when the call is dropped. */
56 start(toolCall: AgentToolCall): NestedToolCallRecord | undefined {
57 if (this.calls.length >= NESTED_CALL_LIMITS.maxCalls) {
58 this.complete = false;
59 return undefined;
60 }
61 const record: NestedToolCallRecord = { id: toolCall.id, name: toolCall.name, status: "unfinished" };
62 const json = JSON.stringify(toolCall.arguments ?? {});
63 const bytes = encoder.encode(json).length;
64 if (
65 bytes > NESTED_CALL_LIMITS.maxArgumentBytesPerCall ||
66 this.argumentBytes + bytes > NESTED_CALL_LIMITS.maxArgumentBytesTotal
67 ) {
68 record.argumentsBytes = bytes;
69 this.complete = false;
70 } else {
71 record.arguments = JSON.parse(json) as JsonObject;
72 this.argumentBytes += bytes;
73 }
74 this.calls.push(record);
75 this.startedAt.set(record, performance.now());
76 return record;
77 }
78
79 finish(record: NestedToolCallRecord | undefined, isError: boolean, errorText: string): void {
80 if (!record) return;
81 record.status = isError ? "error" : "ok";
82 record.durationMs = Math.round(performance.now() - (this.startedAt.get(record) ?? performance.now()));
83 this.startedAt.delete(record);
84 if (isError && errorText) record.error = errorText.slice(0, NESTED_CALL_LIMITS.maxErrorChars);
85 }
86
87 addUsage(usage: Usage): void {
88 this.usage = this.usage ? combineUsage(this.usage, usage) : usage;
89 }
90
91 get totalUsage(): Usage | undefined {
92 return this.usage;
93 }
94
95 /** Copy of the record so far, or undefined when no nested call was made. */
96 snapshot(): NestedToolCalls | undefined {
97 if (this.calls.length === 0 && this.complete) return undefined;
98 const calls = this.calls.map((call) => ({ ...call }));
99 return { calls, complete: this.complete && calls.every((call) => call.status !== "unfinished") };
100 }
101}
102
103export interface NestedToolCallOptions {
104 /** Defaults to the calling tool's signal. */
105 signal?: AbortSignal;
106 /** Receives partial results of the nested tool, in addition to `tool_execution_update` events. */
107 onUpdate?: AgentToolUpdateCallback;
108}
109
110/** `tool_execution_*` events of nested calls. */
111export type NestedToolExecutionEvent =
112 | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown; parentToolCallId: string }
113 | {
114 type: "tool_execution_update";
115 toolCallId: string;
116 toolName: string;
117 args: unknown;
118 partialResult: AgentToolResult<unknown>;
119 parentToolCallId: string;
120 }
121 | {
122 type: "tool_execution_end";
123 toolCallId: string;
124 toolName: string;
125 result: AgentToolResult<unknown>;
126 isError: boolean;
127 parentToolCallId: string;
128 };
129
130export interface NestedToolCallHost {
131 /** Tools nested calls resolve against. */
132 getTools(): readonly AgentTool[];
133 /** Whether every nested call runs exclusively, as when the agent executes tool calls sequentially. */
134 isSequential(): boolean;
135 /** Run the call through the tool pipeline, with hooks that report `parentToolCallId`. */
136 runToolCall(
137 toolCall: AgentToolCall,
138 parentToolCallId: string,
139 signal: AbortSignal | undefined,
140 onUpdate: (partialResult: AgentToolResult<unknown>) => Promise<void>,
141 ): Promise<AgentToolCallOutcome>;
142 emit(event: NestedToolExecutionEvent): Promise<void>;
143}
144
145/** Calls below one model-issued call share its recorder. */
146interface CallScope {
147 recorder: NestedCallRecorder;
148 nextId: number;
149 /** Set inside a call that holds the exclusive queue, so its own nested calls do not wait on it. */
150 holdsQueue: boolean;
151}
152
153function textOf(result: AgentToolResult<unknown>): string {
154 return (result.content ?? [])
155 .filter((block): block is TextContent => block.type === "text")
156 .map((block) => block.text)
157 .join("\n");
158}
159
160export class NestedToolCallRunner {
161 private readonly host: NestedToolCallHost;
162 /** Scopes by the id of the calling tool call. */
163 private readonly scopes = new Map<string, CallScope>();
164 /** Serializes nested calls that must not run concurrently. */
165 private queueTail: Promise<void> = Promise.resolve();
166
167 constructor(host: NestedToolCallHost) {
168 this.host = host;
169 }
170
171 /**
172 * Run `name` on behalf of the call `callerId`. The nested call gets the id `<callerId>/<n>`.
173 * Never rejects for tool failures: they come back as `isError: true`.
174 */
175 async execute(
176 callerId: string,
177 name: string,
178 args: unknown,
179 options: NestedToolCallOptions = {},
180 ): Promise<AgentToolCallOutcome> {
181 let scope = this.scopes.get(callerId);
182 if (!scope) {
183 scope = { recorder: new NestedCallRecorder(), nextId: 1, holdsQueue: false };
184 this.scopes.set(callerId, scope);
185 }
186 const toolCall: AgentToolCall = {
187 type: "toolCall",
188 id: `${callerId}/${scope.nextId++}`,
189 name,
190 arguments: (args ?? {}) as AgentToolCall["arguments"],
191 };
192 const record = scope.recorder.start(toolCall);
193 await this.host.emit({
194 type: "tool_execution_start",
195 toolCallId: toolCall.id,
196 toolName: name,
197 args: toolCall.arguments,
198 parentToolCallId: callerId,
199 });
200
201 const exclusive =
202 !scope.holdsQueue &&
203 (this.host.isSequential() ||
204 this.host.getTools().find((tool) => tool.name === name)?.executionMode === "sequential");
205 let release: (() => void) | undefined;
206 if (exclusive) {
207 const previous = this.queueTail;
208 this.queueTail = new Promise((resolve) => {
209 release = resolve;
210 });
211 await previous;
212 }
213 this.scopes.set(toolCall.id, {
214 recorder: scope.recorder,
215 nextId: 1,
216 holdsQueue: scope.holdsQueue || exclusive,
217 });
218 let outcome: AgentToolCallOutcome;
219 try {
220 outcome = await this.host.runToolCall(toolCall, callerId, options.signal, async (partialResult) => {
221 options.onUpdate?.(partialResult);
222 await this.host.emit({
223 type: "tool_execution_update",
224 toolCallId: toolCall.id,
225 toolName: name,
226 args: toolCall.arguments,
227 partialResult,
228 parentToolCallId: callerId,
229 });
230 });
231 } finally {
232 this.scopes.delete(toolCall.id);
233 release?.();
234 }
235
236 scope.recorder.finish(record, outcome.isError, textOf(outcome.result));
237 // Nested results are not persisted, so their usage is only counted through the recorder.
238 if (outcome.result.usage) scope.recorder.addUsage(outcome.result.usage);
239 await this.host.emit({
240 type: "tool_execution_end",
241 toolCallId: toolCall.id,
242 toolName: name,
243 result: outcome.result,
244 isError: outcome.isError,
245 parentToolCallId: callerId,
246 });
247 return outcome;
248 }
249
250 /** Remove and return the record of the nested calls a model-issued call made. */
251 takeRecord(toolCallId: string): NestedCallSummary | undefined {
252 const scope = this.scopes.get(toolCallId);
253 this.scopes.delete(toolCallId);
254 if (!scope) return undefined;
255 return { calls: scope.recorder.snapshot(), usage: scope.recorder.totalUsage };
256 }
257
258 clear(): void {
259 this.scopes.clear();
260 }
261}