UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

179 lines • 6.87 kB
/** * Tool calls that a tool makes while it runs (`ctx.executeTool()`), for example from codemode * scripts. The agent loop does not know about them: the session runs each one through the agent's * tool pipeline (`runToolCall`) with its own hooks, emits `tool_execution_*` events with * `parentToolCallId`, and records the calls and their usage on the model-issued call's tool result * message. * * Nothing here runs until a tool calls `ctx.executeTool()`. */ import { combineUsage } from "./usage-totals.js"; /** * Limits of the nested-call record on a tool result: arguments * over the per-call or total size are omitted, calls beyond the count are dropped, and the record * is marked incomplete when any of that happens. */ export const NESTED_CALL_LIMITS = { maxCalls: 256, maxArgumentBytesPerCall: 8 * 1024, maxArgumentBytesTotal: 32 * 1024, maxErrorChars: 500, }; const encoder = new TextEncoder(); /** * Collects the nested calls of one model-issued tool call, including calls made by nested tools. * The snapshot becomes `nestedCalls` on the tool result message. */ export class NestedCallRecorder { calls = []; startedAt = new Map(); complete = true; argumentBytes = 0; /** Summed usage of every nested result, including calls dropped from the record. */ usage; /** Record a call as it starts. Returns undefined when the call is dropped. */ start(toolCall) { if (this.calls.length >= NESTED_CALL_LIMITS.maxCalls) { this.complete = false; return undefined; } const record = { id: toolCall.id, name: toolCall.name, status: "unfinished" }; const json = JSON.stringify(toolCall.arguments ?? {}); const bytes = encoder.encode(json).length; if (bytes > NESTED_CALL_LIMITS.maxArgumentBytesPerCall || this.argumentBytes + bytes > NESTED_CALL_LIMITS.maxArgumentBytesTotal) { record.argumentsBytes = bytes; this.complete = false; } else { record.arguments = JSON.parse(json); this.argumentBytes += bytes; } this.calls.push(record); this.startedAt.set(record, performance.now()); return record; } finish(record, isError, errorText) { if (!record) return; record.status = isError ? "error" : "ok"; record.durationMs = Math.round(performance.now() - (this.startedAt.get(record) ?? performance.now())); this.startedAt.delete(record); if (isError && errorText) record.error = errorText.slice(0, NESTED_CALL_LIMITS.maxErrorChars); } addUsage(usage) { this.usage = this.usage ? combineUsage(this.usage, usage) : usage; } get totalUsage() { return this.usage; } /** Copy of the record so far, or undefined when no nested call was made. */ snapshot() { if (this.calls.length === 0 && this.complete) return undefined; const calls = this.calls.map((call) => ({ ...call })); return { calls, complete: this.complete && calls.every((call) => call.status !== "unfinished") }; } } function textOf(result) { return (result.content ?? []) .filter((block) => block.type === "text") .map((block) => block.text) .join("\n"); } export class NestedToolCallRunner { host; /** Scopes by the id of the calling tool call. */ scopes = new Map(); /** Serializes nested calls that must not run concurrently. */ queueTail = Promise.resolve(); constructor(host) { this.host = host; } /** * Run `name` on behalf of the call `callerId`. The nested call gets the id `<callerId>/<n>`. * Never rejects for tool failures: they come back as `isError: true`. */ async execute(callerId, name, args, options = {}) { let scope = this.scopes.get(callerId); if (!scope) { scope = { recorder: new NestedCallRecorder(), nextId: 1, holdsQueue: false }; this.scopes.set(callerId, scope); } const toolCall = { type: "toolCall", id: `${callerId}/${scope.nextId++}`, name, arguments: (args ?? {}), }; const record = scope.recorder.start(toolCall); await this.host.emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: name, args: toolCall.arguments, parentToolCallId: callerId, }); const exclusive = !scope.holdsQueue && (this.host.isSequential() || this.host.getTools().find((tool) => tool.name === name)?.executionMode === "sequential"); let release; if (exclusive) { const previous = this.queueTail; this.queueTail = new Promise((resolve) => { release = resolve; }); await previous; } this.scopes.set(toolCall.id, { recorder: scope.recorder, nextId: 1, holdsQueue: scope.holdsQueue || exclusive, }); let outcome; try { outcome = await this.host.runToolCall(toolCall, callerId, options.signal, async (partialResult) => { options.onUpdate?.(partialResult); await this.host.emit({ type: "tool_execution_update", toolCallId: toolCall.id, toolName: name, args: toolCall.arguments, partialResult, parentToolCallId: callerId, }); }); } finally { this.scopes.delete(toolCall.id); release?.(); } scope.recorder.finish(record, outcome.isError, textOf(outcome.result)); // Nested results are not persisted, so their usage is only counted through the recorder. if (outcome.result.usage) scope.recorder.addUsage(outcome.result.usage); await this.host.emit({ type: "tool_execution_end", toolCallId: toolCall.id, toolName: name, result: outcome.result, isError: outcome.isError, ...(outcome.durationMs === undefined ? {} : { durationMs: outcome.durationMs }), parentToolCallId: callerId, }); return outcome; } /** Remove and return the record of the nested calls a model-issued call made. */ takeRecord(toolCallId) { const scope = this.scopes.get(toolCallId); this.scopes.delete(toolCallId); if (!scope) return undefined; return { calls: scope.recorder.snapshot(), usage: scope.recorder.totalUsage }; } clear() { this.scopes.clear(); } } //# sourceMappingURL=nested-tool-calls.js.map