import type { AgentToolResult } from "@earendil-works/pi-coding-agent"; import { sanitizeTerminalLabel } from "../render/sanitize.ts"; import type { EvalToolCallSummary } from "./types.ts"; export const MAX_ENRICHED_TOOL_CALLS = 30; const MAX_TOOL_CALL_SUMMARIES = 100; const OMITTED_TOOL_CALLS_NAME = "tool-calls-omitted"; const OMITTED_TOOL_CALLS_PATTERN = /^(\d+) earlier tool calls omitted$/; function omittedToolCallCount( summary: EvalToolCallSummary ): number | undefined { if ( summary.name !== OMITTED_TOOL_CALLS_NAME || summary.ok || summary.error === undefined ) { return; } const match = OMITTED_TOOL_CALLS_PATTERN.exec(summary.error); return match === null ? undefined : Number(match[1]); } /** * Push a tool-call summary under a total budget shared by enriched and plain * entries (mirrors the status-events omission-marker discipline). Once the cap * is reached, the oldest entry is replaced by a marker recording how many * earlier calls were omitted, so the retained list stays bounded. */ function pushBoundedToolCallSummary( toolCalls: EvalToolCallSummary[], summary: EvalToolCallSummary ): void { if (toolCalls.length < MAX_TOOL_CALL_SUMMARIES) { toolCalls.push(summary); return; } const first = toolCalls[0]; const omitted = first === undefined ? undefined : omittedToolCallCount(first); if (omitted === undefined) { toolCalls.splice(0, 2, { name: OMITTED_TOOL_CALLS_NAME, ok: false, error: "2 earlier tool calls omitted", }); toolCalls.push(summary); return; } toolCalls.splice(1, 1); toolCalls[0] = { name: OMITTED_TOOL_CALLS_NAME, ok: false, error: `${omitted + 1} earlier tool calls omitted`, }; toolCalls.push(summary); } /** * Record a tool-call summary under the shared capture policy: at most * MAX_ENRICHED_TOOL_CALLS summaries carry full enrichment (callId, args, * durationMs, resultPreview); later calls keep only name/ok/error, and the * total list is capped at MAX_TOOL_CALL_SUMMARIES with an omission marker. * * Single implementation shared by the JS kernel path (CellHandler) and the * HTTP bridge path (subprocess py/rb preludes) so enrichment stays * language-neutral (TECHNICAL.md capture limits). */ export function trackToolCall( toolCalls: EvalToolCallSummary[], summary: EvalToolCallSummary ): void { const enrichedCount = toolCalls.filter( (toolCall) => toolCall.callId !== undefined ).length; if (summary.callId === undefined || enrichedCount < MAX_ENRICHED_TOOL_CALLS) { pushBoundedToolCallSummary(toolCalls, summary); return; } pushBoundedToolCallSummary(toolCalls, { name: summary.name, ok: summary.ok, ...(summary.error === undefined ? {} : { error: summary.error }), }); } const MAX_ARGUMENT_STRING_CODE_POINTS = 512; const MAX_ARGUMENT_ENTRIES = 32; const MAX_ARGUMENT_DEPTH = 6; const MAX_SERIALIZED_ARGUMENT_LENGTH = 4096; const MAX_RESULT_PREVIEW_CODE_POINTS = 160; interface BoundedValue { readonly truncated: boolean; readonly value: unknown; } export function capCodePoints(text: string, max: number): string { let end = 0; let count = 0; while (count < max && end < text.length) { const firstCodeUnit = text.charCodeAt(end); const secondCodeUnit = text.charCodeAt(end + 1); const isSurrogatePair = firstCodeUnit >= 0xd8_00 && firstCodeUnit <= 0xdb_ff && secondCodeUnit >= 0xdc_00 && secondCodeUnit <= 0xdf_ff; end += isSurrogatePair ? 2 : 1; count += 1; } return end === text.length ? text : `${text.slice(0, end)}…`; } function boundValue( value: unknown, depth: number, ancestors: WeakSet ): BoundedValue { if (typeof value === "string") { const capped = capCodePoints(value, MAX_ARGUMENT_STRING_CODE_POINTS); return { value: capped, truncated: capped !== value }; } if (value === null || typeof value !== "object") { return { value, truncated: false }; } if (depth >= MAX_ARGUMENT_DEPTH) { return { value: "…", truncated: true }; } if (ancestors.has(value)) { throw new Error("cyclic tool-call arguments"); } ancestors.add(value); try { if (Array.isArray(value)) { const retainedLength = Math.min(value.length, MAX_ARGUMENT_ENTRIES); const clone: unknown[] = []; let truncated = value.length > MAX_ARGUMENT_ENTRIES; for (let index = 0; index < retainedLength; index += 1) { const bounded = boundValue(value[index], depth + 1, ancestors); clone.push(bounded.value); truncated ||= bounded.truncated; } return { value: clone, truncated }; } const entries: [string, unknown][] = []; let truncated = false; let count = 0; for (const [key, nestedValue] of Object.entries(value)) { if (count >= MAX_ARGUMENT_ENTRIES) { truncated = true; break; } const bounded = boundValue(nestedValue, depth + 1, ancestors); entries.push([key, bounded.value]); truncated ||= bounded.truncated; count += 1; } return { value: Object.fromEntries(entries), truncated }; } finally { ancestors.delete(value); } } export function boundToolCallArgs(args: unknown): { args: unknown; truncated: boolean; } { try { const bounded = boundValue(args, 0, new WeakSet()); const serialized = JSON.stringify(bounded.value); if ( typeof serialized !== "string" || serialized.length > MAX_SERIALIZED_ARGUMENT_LENGTH ) { return { args: undefined, truncated: true }; } return { args: bounded.value, truncated: bounded.truncated }; } catch { return { args: undefined, truncated: true }; } } export function toolCallResultPreview( result: AgentToolResult ): string | undefined { for (const part of result.content) { if (part.type !== "text") { continue; } const preview = sanitizeTerminalLabel(part.text) .replace(/\s+/gu, " ") .trim(); return preview.length === 0 ? undefined : capCodePoints(preview, MAX_RESULT_PREVIEW_CODE_POINTS); } }