1
/**2
* Tool calls that a tool makes while it runs (`ctx.executeTool()`), for example from codemode3
* scripts. The agent loop does not know about them: the session runs each one through the agent's4
* tool pipeline (`runToolCall`) with its own hooks, emits `tool_execution_*` events with5
* `parentToolCallId`, and records the calls and their usage on the model-issued call's tool result6
* message.7
*8
* Nothing here runs until a tool calls `ctx.executeTool()`.9
*/11
import type {12
AgentTool,13
AgentToolCall,14
AgentToolCallOutcome,15
AgentToolResult,16
AgentToolUpdateCallback,17
} from "@earendil-works/pi-agent-core";18
import type { JsonObject, NestedToolCallRecord, NestedToolCalls, TextContent, Usage } from "@earendil-works/pi-ai";19
import { combineUsage } from "./usage-totals.ts";21
/**22
* Limits of the nested-call record on a tool result: arguments23
* over the per-call or total size are omitted, calls beyond the count are dropped, and the record24
* is marked incomplete when any of that happens.25
*/26
export const NESTED_CALL_LIMITS = {27
maxCalls: 256,28
maxArgumentBytesPerCall: 8 * 1024,29
maxArgumentBytesTotal: 32 * 1024,30
maxErrorChars: 500,31
} as const;33
const encoder = new TextEncoder();35
/** What the nested calls of one model-issued tool call leave on its tool result message. */36
export 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
}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
*/47
export 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;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.maxArgumentBytesTotal67
) {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
}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
}87
addUsage(usage: Usage): void {88
this.usage = this.usage ? combineUsage(this.usage, usage) : usage;89
}91
get totalUsage(): Usage | undefined {92
return this.usage;93
}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
}103
export 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
}110
/** `tool_execution_*` events of nested calls. */111
export 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
};130
export 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
}145
/** Calls below one model-issued call share its recorder. */146
interface 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
}153
function 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
}160
export 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();167
constructor(host: NestedToolCallHost) {168
this.host = host;169
}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
});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
}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
}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
}258
clear(): void {259
this.scopes.clear();260
}261
}