@caupulican/pi-agent-core 0.81.39 → 0.81.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +10 -5
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +77 -89
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/compaction/branch-summarization.d.ts +4 -2
  6. package/dist/compaction/branch-summarization.d.ts.map +1 -1
  7. package/dist/compaction/branch-summarization.js +4 -0
  8. package/dist/compaction/branch-summarization.js.map +1 -1
  9. package/dist/compaction/compaction.d.ts +32 -1
  10. package/dist/compaction/compaction.d.ts.map +1 -1
  11. package/dist/compaction/compaction.js +172 -101
  12. package/dist/compaction/compaction.js.map +1 -1
  13. package/dist/compaction/extraction.d.ts.map +1 -1
  14. package/dist/compaction/extraction.js +1 -1
  15. package/dist/compaction/extraction.js.map +1 -1
  16. package/dist/compaction/loop.d.ts +1 -1
  17. package/dist/compaction/loop.d.ts.map +1 -1
  18. package/dist/compaction/loop.js +24 -7
  19. package/dist/compaction/loop.js.map +1 -1
  20. package/dist/compaction/utils.d.ts.map +1 -1
  21. package/dist/compaction/utils.js +3 -1
  22. package/dist/compaction/utils.js.map +1 -1
  23. package/dist/compaction/verification.d.ts +11 -0
  24. package/dist/compaction/verification.d.ts.map +1 -1
  25. package/dist/compaction/verification.js +93 -11
  26. package/dist/compaction/verification.js.map +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/proxy.d.ts +1 -1
  32. package/dist/proxy.d.ts.map +1 -1
  33. package/dist/proxy.js +1 -0
  34. package/dist/proxy.js.map +1 -1
  35. package/dist/reliability/classifier.d.ts.map +1 -1
  36. package/dist/reliability/classifier.js +3 -3
  37. package/dist/reliability/classifier.js.map +1 -1
  38. package/dist/session/session-manager.d.ts +18 -6
  39. package/dist/session/session-manager.d.ts.map +1 -1
  40. package/dist/session/session-manager.js +68 -61
  41. package/dist/session/session-manager.js.map +1 -1
  42. package/dist/tool-failure-memory.d.ts +35 -0
  43. package/dist/tool-failure-memory.d.ts.map +1 -0
  44. package/dist/tool-failure-memory.js +290 -0
  45. package/dist/tool-failure-memory.js.map +1 -0
  46. package/dist/types.d.ts +5 -1
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js.map +1 -1
  49. package/dist/usage.d.ts +5 -0
  50. package/dist/usage.d.ts.map +1 -1
  51. package/dist/usage.js +32 -0
  52. package/dist/usage.js.map +1 -1
  53. package/dist/uuid.d.ts +1 -1
  54. package/dist/uuid.d.ts.map +1 -1
  55. package/dist/uuid.js +1 -49
  56. package/dist/uuid.js.map +1 -1
  57. package/package.json +2 -2
package/README.md CHANGED
@@ -44,12 +44,13 @@ LLMs only understand `user`, `assistant`, and `toolResult`. The `convertToLlm` f
44
44
  ### Message Flow
45
45
 
46
46
  ```
47
- AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
48
- (optional) (required)
47
+ AgentMessage[] → failure boundary → transformContext() → convertToLlm() → Message[] → LLM
48
+ (optional) (required)
49
49
  ```
50
50
 
51
- 1. **transformContext**: Prune old messages, inject external context
52
- 2. **convertToLlm**: Filter out UI-only messages, convert custom types to LLM format
51
+ 1. **failure boundary**: Remove failed tool protocol turns and retain only bounded unresolved failure records
52
+ 2. **transformContext**: Prune old messages, inject external context
53
+ 3. **convertToLlm**: Filter out UI-only messages, convert custom types to LLM format
53
54
 
54
55
  ## Event Flow
55
56
 
@@ -427,7 +428,11 @@ execute: async (toolCallId, params, signal, onUpdate) => {
427
428
  }
428
429
  ```
429
430
 
430
- Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
431
+ Thrown errors are caught and persisted as bounded `isError: true` records containing a stable operation key,
432
+ occurrence count, state, tool name, failure code, and corrective action from the shared repair catalogue. Raw
433
+ failure output and failed tool protocol turns do not reach later provider requests. The unresolved record teaches
434
+ the next attempt without replaying the error payload, and a later successful execution of that operation clears
435
+ the reminder.
431
436
 
432
437
  Return `terminate: true` from `execute()` or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results.
433
438
 
@@ -1 +1 @@
1
- {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAGN,WAAW,EAQX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAIZ,QAAQ,EAER,MAAM,YAAY,CAAC;AAIpB,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAEzE;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAuBzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CA8BzC;AAED,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAgBzB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAiBzB","sourcesContent":["/**\n * Agent loop that works with AgentMessage throughout.\n * Transforms to Message[] only at the LLM call boundary.\n */\n\nimport {\n\ttype AssistantMessage,\n\ttype Context,\n\tEventStream,\n\tgetToolExecutionErrorGuidance,\n\tstreamSimple,\n\ttype ToolArgumentExecutionOutcome,\n\tToolArgumentValidationError,\n\ttype ToolArgumentValidationTelemetryEvent,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"@caupulican/pi-ai\";\nimport type {\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentMessage,\n\tAgentTool,\n\tAgentToolCall,\n\tAgentToolResult,\n\tStreamFn,\n\tToolCallRepairInfo,\n} from \"./types.ts\";\nimport { DEFAULT_MAX_STALL_TURNS } from \"./types.ts\";\nimport { createEmptyUsage } from \"./usage.ts\";\n\nexport type AgentEventSink = (event: AgentEvent) => Promise<void> | void;\n\n/**\n * Start an agent loop with a new prompt message.\n * The prompt is added to the context and events are emitted for it.\n */\nexport function agentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoop(\n\t\tprompts,\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t)\n\t\t.catch(async (error) => {\n\t\t\tconst messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];\n\t\t\tstream.push({ type: \"agent_end\", messages });\n\t\t\treturn messages;\n\t\t})\n\t\t.then((messages) => {\n\t\t\tstream.end(messages);\n\t\t});\n\n\treturn stream;\n}\n\n/**\n * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t)\n\t\t.catch(async (error) => {\n\t\t\tconst messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];\n\t\t\tstream.push({ type: \"agent_end\", messages });\n\t\t\treturn messages;\n\t\t})\n\t\t.then((messages) => {\n\t\t\tstream.end(messages);\n\t\t});\n\n\treturn stream;\n}\n\nexport async function runAgentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tconst newMessages: AgentMessage[] = [...prompts];\n\tconst currentContext: AgentContext = {\n\t\t...context,\n\t\tmessages: [...context.messages, ...prompts],\n\t};\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\tfor (const prompt of prompts) {\n\t\tawait emit({ type: \"message_start\", message: prompt });\n\t\tawait emit({ type: \"message_end\", message: prompt });\n\t}\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nexport async function runAgentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst newMessages: AgentMessage[] = [];\n\tconst currentContext: AgentContext = { ...context, messages: [...context.messages] };\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nfunction createLoopFailureMessage(error: unknown, config: AgentLoopConfig, aborted: boolean): AssistantMessage {\n\treturn {\n\t\trole: \"assistant\",\n\t\tcontent: [{ type: \"text\", text: \"\" }],\n\t\tapi: config.model.api,\n\t\tprovider: config.model.provider,\n\t\tmodel: config.model.id,\n\t\tusage: createEmptyUsage(),\n\t\tstopReason: aborted ? \"aborted\" : \"error\",\n\t\terrorMessage: error instanceof Error ? error.message : String(error),\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nfunction createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {\n\treturn new EventStream<AgentEvent, AgentMessage[]>(\n\t\t(event: AgentEvent) => event.type === \"agent_end\",\n\t\t(event: AgentEvent) => (event.type === \"agent_end\" ? event.messages : []),\n\t);\n}\n\n/**\n * How many `stallLimit`-length periods the runaway-loop window spans. A window of `stallLimit * P`\n * turns lets the count-based detector catch oscillating cycles of period up to `P` (each signature in a\n * period-k cycle recurs ~window/k times), not just back-to-back repeats. Beyond this the cycle is loose\n * enough that it's indistinguishable from legitimate varied work, so we don't chase it.\n */\nconst STALL_WINDOW_PERIODS = 4;\n\n/**\n * Normalize a tool-call batch into a stable signature for runaway-loop detection. Volatile argument\n * tokens — epoch/timestamps, UUIDs, long hashes/nonces — are masked so a model retrying the SAME call\n * with a fresh timestamp/id each turn still collapses to one signature and is detected (bug #28). Only\n * clearly-volatile patterns are masked: short numbers (`file2.ts`, `line 42`, `count: 3`) are kept so\n * genuinely-distinct calls (reading numbered files, different line ranges) are NOT falsely merged.\n */\nfunction normalizeToolSignature(pairs: Array<[string, unknown]>): string {\n\treturn JSON.stringify(pairs)\n\t\t.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, \"<uuid>\")\n\t\t.replace(/\\d{4}-\\d{2}-\\d{2}[tT][0-9:.]+(?:z|[+-]\\d{2}:?\\d{2})?/gi, \"<ts>\")\n\t\t.replace(/\\b[0-9a-f]{16,}\\b/gi, \"<hex>\")\n\t\t.replace(/\\d{10,}/g, \"<num>\");\n}\n\n/**\n * Main loop logic shared by agentLoop and agentLoopContinue.\n */\nasync function runLoop(\n\tinitialContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tinitialConfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<void> {\n\tlet currentContext = initialContext;\n\tlet config = initialConfig;\n\tlet firstTurn = true;\n\t// Runaway-loop backstop state: a sliding window of recent NORMALIZED tool-call signatures. A model\n\t// wedged repeating the same action makes no progress but keeps spending tokens; if one signature\n\t// recurs `stallLimit` times within the window we stop gracefully. Signatures are normalized so\n\t// volatile args (timestamps/UUIDs/nonces that change every call) can't disguise an otherwise-\n\t// identical call (bug #28). The window spans `stallLimit * STALL_WINDOW_PERIODS` turns so periodic\n\t// oscillation is caught too, not just back-to-back repeats: a cycle of period P repeats each\n\t// signature ~window/P times, so any P up to STALL_WINDOW_PERIODS reaches the threshold before the\n\t// window slides past it. Counts only turns that issued tool calls, so varied/long work never trips\n\t// it. `0` disables.\n\tconst stallLimit = config.maxStallTurns ?? DEFAULT_MAX_STALL_TURNS;\n\tconst stallWindow: string[] = [];\n\tconst validationFailureTracker: ToolValidationFailureTracker = { repeats: 0 };\n\tconst repairTeachTracker: ToolRepairTeachTracker = new Map();\n\tconst executionFailureTracker: ToolExecutionFailureTracker = { repeats: 0 };\n\t// Check for steering messages at start (user may have typed while waiting)\n\tlet pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];\n\n\t// Outer loop: continues when queued follow-up messages arrive after agent would stop\n\twhile (true) {\n\t\tlet hasMoreToolCalls = true;\n\n\t\t// Inner loop: process tool calls and steering messages\n\t\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\t\tif (!firstTurn) {\n\t\t\t\tawait emit({ type: \"turn_start\" });\n\t\t\t} else {\n\t\t\t\tfirstTurn = false;\n\t\t\t}\n\n\t\t\t// Process pending messages (inject before next assistant response)\n\t\t\tif (pendingMessages.length > 0) {\n\t\t\t\tfor (const message of pendingMessages) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message });\n\t\t\t\t\tawait emit({ type: \"message_end\", message });\n\t\t\t\t\tcurrentContext.messages.push(message);\n\t\t\t\t\tnewMessages.push(message);\n\t\t\t\t}\n\t\t\t\tpendingMessages = [];\n\t\t\t}\n\n\t\t\t// Stream assistant response\n\t\t\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\t\t\tnewMessages.push(message);\n\n\t\t\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\t\t\tawait emit({ type: \"turn_end\", message, toolResults: [] });\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for tool calls\n\t\t\tconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n\n\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\thasMoreToolCalls = false;\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tconst executedToolBatch = await executeToolCalls(\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tmessage,\n\t\t\t\t\tconfig,\n\t\t\t\t\tvalidationFailureTracker,\n\t\t\t\t\trepairTeachTracker,\n\t\t\t\t\texecutionFailureTracker,\n\t\t\t\t\tsignal,\n\t\t\t\t\temit,\n\t\t\t\t);\n\t\t\t\ttoolResults.push(...executedToolBatch.messages);\n\t\t\t\thasMoreToolCalls = !executedToolBatch.terminate;\n\n\t\t\t\tfor (const result of toolResults) {\n\t\t\t\t\tcurrentContext.messages.push(result);\n\t\t\t\t\tnewMessages.push(result);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\n\t\t\t// Runaway-loop backstop (cost guard): detect a model stuck repeating one action.\n\t\t\tif (stallLimit > 0 && toolCalls.length > 0) {\n\t\t\t\tconst signature = normalizeToolSignature(toolCalls.map((c) => [c.name, c.arguments ?? null]));\n\t\t\t\tstallWindow.push(signature);\n\t\t\t\tif (stallWindow.length > stallLimit * STALL_WINDOW_PERIODS) stallWindow.shift();\n\t\t\t\tconst repeats = stallWindow.reduce((n, s) => (s === signature ? n + 1 : n), 0);\n\t\t\t\tif (repeats >= stallLimit) {\n\t\t\t\t\tconfig.onRunawayStop?.({ signature, repeats });\n\t\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst nextTurnContext = {\n\t\t\t\tmessage,\n\t\t\t\ttoolResults,\n\t\t\t\tcontext: currentContext,\n\t\t\t\tnewMessages,\n\t\t\t};\n\t\t\tconst nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);\n\t\t\tif (nextTurnSnapshot) {\n\t\t\t\tcurrentContext = nextTurnSnapshot.context ?? currentContext;\n\t\t\t\tconfig = {\n\t\t\t\t\t...config,\n\t\t\t\t\tmodel: nextTurnSnapshot.model ?? config.model,\n\t\t\t\t\treasoning: nextTurnSnapshot.thinkingLevel ?? config.reasoning,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tawait config.shouldStopAfterTurn?.({\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolResults,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\tnewMessages,\n\t\t\t\t})\n\t\t\t) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpendingMessages = (await config.getSteeringMessages?.()) || [];\n\t\t}\n\n\t\t// Agent would stop here. Check for follow-up messages.\n\t\tconst followUpMessages = (await config.getFollowUpMessages?.()) || [];\n\t\tif (followUpMessages.length > 0) {\n\t\t\t// Set as pending so inner loop processes them\n\t\t\tpendingMessages = followUpMessages;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No more messages, exit\n\t\tbreak;\n\t}\n\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/**\n * Stream an assistant response from the LLM.\n * This is where AgentMessage[] gets transformed to Message[] for the LLM.\n */\nasync function streamAssistantResponse(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantMessage> {\n\t// Apply context transform if configured (AgentMessage[] → AgentMessage[])\n\tlet messages = context.messages;\n\tif (config.transformContext) {\n\t\tmessages = await config.transformContext(messages, signal);\n\t}\n\n\t// Convert to LLM-compatible messages (AgentMessage[] → Message[])\n\tconst llmMessages = await config.convertToLlm(messages);\n\n\t// Build LLM context\n\tconst llmContext: Context = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\tmessages: llmMessages,\n\t\ttools: context.tools,\n\t};\n\n\tconst streamFunction = streamFn || streamSimple;\n\n\t// Resolve API key (important for expiring tokens)\n\tconst resolvedApiKey =\n\t\t(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;\n\tconst requestReasoning = config.resolveRequestReasoning\n\t\t? config.resolveRequestReasoning(config.reasoning, {\n\t\t\t\tmodel: config.model,\n\t\t\t\tcontext: llmContext,\n\t\t\t\tmaxTokens: config.maxTokens,\n\t\t\t})\n\t\t: config.reasoning;\n\n\tconst response = await streamFunction(config.model, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\treasoning: requestReasoning,\n\t\tsignal,\n\t});\n\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\n\n\tfor await (const event of response) {\n\t\tswitch (event.type) {\n\t\t\tcase \"start\":\n\t\t\t\tpartialMessage = event.partial;\n\t\t\t\tcontext.messages.push(partialMessage);\n\t\t\t\taddedPartial = true;\n\t\t\t\tawait emit({ type: \"message_start\", message: { ...partialMessage } });\n\t\t\t\tbreak;\n\n\t\t\tcase \"text_start\":\n\t\t\tcase \"text_delta\":\n\t\t\tcase \"text_end\":\n\t\t\tcase \"thinking_start\":\n\t\t\tcase \"thinking_delta\":\n\t\t\tcase \"thinking_end\":\n\t\t\tcase \"toolcall_start\":\n\t\t\tcase \"toolcall_delta\":\n\t\t\tcase \"toolcall_end\":\n\t\t\t\tif (partialMessage) {\n\t\t\t\t\tpartialMessage = event.partial;\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = partialMessage;\n\t\t\t\t\tawait emit({\n\t\t\t\t\t\ttype: \"message_update\",\n\t\t\t\t\t\tassistantMessageEvent: event,\n\t\t\t\t\t\tmessage: { ...partialMessage },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"done\":\n\t\t\tcase \"error\": {\n\t\t\t\tconst finalMessage = await response.result();\n\t\t\t\tif (addedPartial) {\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t\t\t\t} else {\n\t\t\t\t\tcontext.messages.push(finalMessage);\n\t\t\t\t}\n\t\t\t\tif (!addedPartial) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t\t\t\t}\n\t\t\t\tawait emit({ type: \"message_end\", message: finalMessage });\n\t\t\t\treturn finalMessage;\n\t\t\t}\n\t\t}\n\t}\n\n\tconst finalMessage = await response.result();\n\tif (addedPartial) {\n\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t} else {\n\t\tcontext.messages.push(finalMessage);\n\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t}\n\tawait emit({ type: \"message_end\", message: finalMessage });\n\treturn finalMessage;\n}\n\n/**\n * Execute tool calls from an assistant message.\n */\nasync function executeToolCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\texecutionFailureTracker: ToolExecutionFailureTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === \"sequential\",\n\t);\n\tif (config.toolExecution === \"sequential\" || hasSequentialToolCall) {\n\t\treturn executeToolCallsSequential(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCalls,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\trepairTeachTracker,\n\t\t\texecutionFailureTracker,\n\t\t\tsignal,\n\t\t\temit,\n\t\t);\n\t}\n\treturn executeToolCallsParallel(\n\t\tcurrentContext,\n\t\tassistantMessage,\n\t\ttoolCalls,\n\t\tconfig,\n\t\tvalidationFailureTracker,\n\t\trepairTeachTracker,\n\t\texecutionFailureTracker,\n\t\tsignal,\n\t\temit,\n\t);\n}\n\ntype ExecutedToolCallBatch = {\n\tmessages: ToolResultMessage[];\n\tterminate: boolean;\n};\n\nasync function executeToolCallsSequential(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\texecutionFailureTracker: ToolExecutionFailureTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tconst messages: ToolResultMessage[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCall,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\tsignal,\n\t\t);\n\t\tawait emitToolExecutionStart(toolCall, emit);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tresetExecutionFailureTracker(executionFailureTracker);\n\t\t\temitToolArgumentValidationTelemetry(config, preparation.validationEvent, \"not_run\", \"none\");\n\t\t\tfinalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t};\n\t\t} else {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tfinalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\trepairTeachTracker,\n\t\t\t\texecutionFailureTracker,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t}\n\n\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tfinalizedCalls.push(finalized);\n\t\tmessages.push(toolResultMessage);\n\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(finalizedCalls),\n\t};\n}\n\nasync function executeToolCallsParallel(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\texecutionFailureTracker: ToolExecutionFailureTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCall,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\tsignal,\n\t\t);\n\t\tawait emitToolExecutionStart(toolCall, emit);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tresetExecutionFailureTracker(executionFailureTracker);\n\t\t\temitToolArgumentValidationTelemetry(config, preparation.validationEvent, \"not_run\", \"none\");\n\t\t\tconst finalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t} satisfies FinalizedToolCallOutcome;\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\tfinalizedCalls.push(finalized);\n\t\t\tif (signal?.aborted) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tfinalizedCalls.push(async () => {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tconst finalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\trepairTeachTracker,\n\t\t\t\texecutionFailureTracker,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\treturn finalized;\n\t\t});\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst orderedFinalizedCalls = await Promise.all(\n\t\tfinalizedCalls.map((entry) => (typeof entry === \"function\" ? entry() : Promise.resolve(entry))),\n\t);\n\tconst messages: ToolResultMessage[] = [];\n\tfor (const finalized of orderedFinalizedCalls) {\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tmessages.push(toolResultMessage);\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(orderedFinalizedCalls),\n\t};\n}\n\ntype PreparedToolCall = {\n\tkind: \"prepared\";\n\ttoolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\targs: unknown;\n\tvalidationEvent?: ToolArgumentValidationTelemetryEvent;\n};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\tvalidationEvent?: ToolArgumentValidationTelemetryEvent;\n};\n\ntype ExecutedToolCallOutcome = {\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\terrorClass?: string;\n};\n\ntype FinalizedToolCallOutcome = {\n\ttoolCall: AgentToolCall;\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\n\ntype ToolValidationFailureTracker = {\n\tsignature?: string;\n\trepeats: number;\n\tescalatedSignature?: string;\n};\n\ntype ToolRepairTeachTracker = Map<string, number>;\n\ntype ToolExecutionFailureTracker = {\n\tsignature?: string;\n\trepeats: number;\n\ttaughtSignature?: string;\n};\n\nconst DEFAULT_TOOL_VALIDATION_ESCALATION_THRESHOLD = 3;\nconst TOOL_REPAIR_TEACH_EVERY = 5;\nconst TOOL_EXECUTION_FAILURE_TEACH_AT = 2;\n\nfunction shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {\n\treturn finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);\n}\n\nfunction prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {\n\tif (!tool.prepareArguments) {\n\t\treturn toolCall;\n\t}\n\tconst preparedArguments = tool.prepareArguments(toolCall.arguments);\n\tif (preparedArguments === toolCall.arguments) {\n\t\treturn toolCall;\n\t}\n\treturn {\n\t\t...toolCall,\n\t\targuments: preparedArguments as Record<string, any>,\n\t};\n}\n\nfunction createValidationBounceTelemetry(\n\tconfig: AgentLoopConfig,\n\ttoolCall: AgentToolCall,\n\terrorKeyword: string,\n): ToolArgumentValidationTelemetryEvent {\n\treturn {\n\t\toutcome: \"bounced\",\n\t\tprovider: config.model.provider,\n\t\tmodel: config.model.id,\n\t\ttool: toolCall.name,\n\t\tsource: toolCall.source,\n\t\tfailureModes: [\"other\"],\n\t\trepairsApplied: [],\n\t\terrorKeywords: [errorKeyword],\n\t\ttaught: \"none\",\n\t\texecutionOutcome: \"not_run\",\n\t};\n}\n\nfunction resetValidationFailureTracker(tracker: ToolValidationFailureTracker): void {\n\ttracker.signature = undefined;\n\ttracker.repeats = 0;\n}\n\nfunction emitToolArgumentValidationTelemetry(\n\tconfig: AgentLoopConfig,\n\tevent: ToolArgumentValidationTelemetryEvent | undefined,\n\texecutionOutcome: ToolArgumentExecutionOutcome,\n\ttaught: ToolArgumentValidationTelemetryEvent[\"taught\"],\n): void {\n\tif (!event) return;\n\tconfig.onToolArgumentValidation?.({ ...event, executionOutcome, taught });\n}\n\nfunction toolValidationEscalationThreshold(config: AgentLoopConfig): number {\n\treturn config.toolValidationEscalationThreshold ?? DEFAULT_TOOL_VALIDATION_ESCALATION_THRESHOLD;\n}\n\nfunction isToolArgumentRepairEmergencyDisabled(): boolean {\n\tconst env = typeof process === \"object\" && process ? process.env : undefined;\n\tconst value = env?.PI_TOOL_REPAIR_DISABLED;\n\tif (!value) return false;\n\treturn [\"1\", \"true\", \"yes\", \"on\"].includes(value.trim().toLowerCase());\n}\n\nfunction isToolArgumentValidationError(error: unknown): error is ToolArgumentValidationError {\n\treturn (\n\t\terror instanceof ToolArgumentValidationError ||\n\t\t(error instanceof Error &&\n\t\t\terror.name === \"ToolArgumentValidationError\" &&\n\t\t\ttypeof (error as { toolName?: unknown }).toolName === \"string\" &&\n\t\t\ttypeof (error as { signature?: unknown }).signature === \"string\" &&\n\t\t\ttypeof (error as { enrichment?: unknown }).enrichment === \"string\")\n\t);\n}\n\nfunction handleValidationFailure(\n\terror: ToolArgumentValidationError,\n\tconfig: AgentLoopConfig,\n\ttracker: ToolValidationFailureTracker,\n): string {\n\tif (tracker.signature === error.signature) {\n\t\ttracker.repeats++;\n\t} else {\n\t\ttracker.signature = error.signature;\n\t\ttracker.repeats = 1;\n\t\ttracker.escalatedSignature = undefined;\n\t}\n\n\tconst threshold = toolValidationEscalationThreshold(config);\n\tif (threshold <= 0 || tracker.repeats < threshold || tracker.escalatedSignature === error.signature) {\n\t\treturn error.message;\n\t}\n\n\ttracker.escalatedSignature = error.signature;\n\tconfig.onToolValidationEscalation?.({\n\t\ttool: error.toolName,\n\t\tsignature: error.signature,\n\t\trepeats: tracker.repeats,\n\t\tmodel: config.model.id,\n\t\tprovider: config.model.provider,\n\t});\n\treturn `${error.message}\\n\\nRepeated validation failure (${tracker.repeats} identical attempts). Use this full schema and example before retrying:\\n${error.enrichment}`;\n}\n\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\tif (toolCall.errorMessage) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(toolCall.errorMessage),\n\t\t\tisError: true,\n\t\t\tvalidationEvent: createValidationBounceTelemetry(config, toolCall, \"unknown_tool\"),\n\t\t};\n\t}\n\n\tconst tool = currentContext.tools?.find((t) => t.name === toolCall.name);\n\tif (!tool) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(`Tool ${toolCall.name} not found`),\n\t\t\tisError: true,\n\t\t\tvalidationEvent: createValidationBounceTelemetry(config, toolCall, \"unknown_tool\"),\n\t\t};\n\t}\n\n\tlet validationEvent: ToolArgumentValidationTelemetryEvent | undefined;\n\ttry {\n\t\tconst preparedToolCall = prepareToolCallArguments(tool, toolCall);\n\t\tconst validatedArgs = validateToolArguments(tool, preparedToolCall, {\n\t\t\tmodel: config.model.id,\n\t\t\tprovider: config.model.provider,\n\t\t\trepairEnabled: !isToolArgumentRepairEmergencyDisabled(),\n\t\t\ttelemetry: (event) => {\n\t\t\t\tvalidationEvent = event;\n\t\t\t},\n\t\t});\n\t\tresetValidationFailureTracker(validationFailureTracker);\n\t\tif (preparedToolCall.repairNotes) {\n\t\t\ttoolCall.repairNotes = preparedToolCall.repairNotes;\n\t\t}\n\t\tif (validatedArgs !== toolCall.arguments) {\n\t\t\ttoolCall.rawArguments ??= toolCall.arguments;\n\t\t\ttoolCall.arguments = validatedArgs;\n\t\t}\n\t\tif (config.beforeToolCall) {\n\t\t\tconst beforeResult = await config.beforeToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall,\n\t\t\t\t\targs: validatedArgs,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (signal?.aborted) {\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\t\tisError: true,\n\t\t\t\t\tvalidationEvent,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (beforeResult?.block) {\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(beforeResult.reason || \"Tool execution was blocked\"),\n\t\t\t\t\tisError: true,\n\t\t\t\t\tvalidationEvent,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t\tif (signal?.aborted) {\n\t\t\treturn {\n\t\t\t\tkind: \"immediate\",\n\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\tisError: true,\n\t\t\t\tvalidationEvent,\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tkind: \"prepared\",\n\t\t\ttoolCall,\n\t\t\ttool,\n\t\t\targs: validatedArgs,\n\t\t\tvalidationEvent,\n\t\t};\n\t} catch (error) {\n\t\tconst message = isToolArgumentValidationError(error)\n\t\t\t? handleValidationFailure(error, config, validationFailureTracker)\n\t\t\t: error instanceof Error\n\t\t\t\t? error.message\n\t\t\t\t: String(error);\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(message),\n\t\t\tisError: true,\n\t\t\tvalidationEvent,\n\t\t};\n\t}\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tconst updateEvents: Promise<void>[] = [];\n\n\ttry {\n\t\tconst result = await prepared.tool.execute(\n\t\t\tprepared.toolCall.id,\n\t\t\tprepared.args as never,\n\t\t\tsignal,\n\t\t\t(partialResult) => {\n\t\t\t\tupdateEvents.push(\n\t\t\t\t\tPromise.resolve(\n\t\t\t\t\t\temit({\n\t\t\t\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\t\t\t\targs: prepared.toolCall.arguments,\n\t\t\t\t\t\t\tpartialResult,\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t},\n\t\t);\n\t\tawait Promise.all(updateEvents);\n\t\treturn { result, isError: false };\n\t} catch (error) {\n\t\tawait Promise.all(updateEvents);\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\treturn {\n\t\t\tresult: createErrorToolResultWithGuidance(message),\n\t\t\tisError: true,\n\t\t\terrorClass: error instanceof Error ? error.name : typeof error,\n\t\t};\n\t}\n}\n\nfunction repairTeachKey(toolName: string, note: string): string {\n\tconst repairName = /^\\[harness\\] ([^:]+):/.exec(note)?.[1] ?? note;\n\treturn `${toolName}\\0${repairName}`;\n}\n\nfunction shouldEmitRepairTeachNote(toolName: string, note: string, tracker: ToolRepairTeachTracker): boolean {\n\tconst key = repairTeachKey(toolName, note);\n\tconst count = (tracker.get(key) ?? 0) + 1;\n\ttracker.set(key, count);\n\treturn count === 1 || count % TOOL_REPAIR_TEACH_EVERY === 0;\n}\n\nfunction appendRepairTeachNotes(\n\tresult: AgentToolResult<any>,\n\ttoolCall: AgentToolCall,\n\ttracker: ToolRepairTeachTracker,\n\tconfig: AgentLoopConfig,\n): { result: AgentToolResult<any>; taught: boolean } {\n\tif (config.toolArgumentTeachEnabled === false) return { result, taught: false };\n\tconst notes = (toolCall.repairNotes ?? []).filter((note) => shouldEmitRepairTeachNote(toolCall.name, note, tracker));\n\tif (notes.length === 0) return { result, taught: false };\n\treturn {\n\t\tresult: {\n\t\t\t...result,\n\t\t\tcontent: [...result.content, { type: \"text\", text: notes.join(\"\\n\") }],\n\t\t},\n\t\ttaught: true,\n\t};\n}\n\nfunction resetExecutionFailureTracker(tracker: ToolExecutionFailureTracker): void {\n\ttracker.signature = undefined;\n\ttracker.repeats = 0;\n}\n\nfunction executionFailureSignature(prepared: PreparedToolCall, errorClass: string): string {\n\treturn `${normalizeToolSignature([[prepared.toolCall.name, prepared.args]])}\\0${errorClass}`;\n}\n\nfunction appendExecutionFailureTeachNote(\n\tresult: AgentToolResult<any>,\n\tprepared: PreparedToolCall,\n\terrorClass: string | undefined,\n\ttracker: ToolExecutionFailureTracker,\n): AgentToolResult<any> {\n\tconst signature = executionFailureSignature(prepared, errorClass ?? \"tool-error\");\n\tif (tracker.signature === signature) {\n\t\ttracker.repeats++;\n\t} else {\n\t\ttracker.signature = signature;\n\t\ttracker.repeats = 1;\n\t}\n\tif (tracker.repeats !== TOOL_EXECUTION_FAILURE_TEACH_AT || tracker.taughtSignature === signature) return result;\n\ttracker.taughtSignature = signature;\n\treturn {\n\t\t...result,\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: \"text\",\n\t\t\t\ttext: `[harness] This exact ${prepared.toolCall.name} call failed twice with the same ${errorClass ?? \"tool\"} error. Change the arguments or approach; do not resend the identical call.`,\n\t\t\t},\n\t\t\t...result.content,\n\t\t],\n\t};\n}\n\nasync function finalizeExecutedToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tprepared: PreparedToolCall,\n\texecuted: ExecutedToolCallOutcome,\n\tconfig: AgentLoopConfig,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\texecutionFailureTracker: ToolExecutionFailureTracker,\n\tsignal: AbortSignal | undefined,\n): Promise<FinalizedToolCallOutcome> {\n\tlet result = executed.result;\n\tlet isError = executed.isError;\n\n\tif (config.afterToolCall) {\n\t\ttry {\n\t\t\tconst afterResult = await config.afterToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall: prepared.toolCall,\n\t\t\t\t\targs: prepared.args,\n\t\t\t\t\tresult,\n\t\t\t\t\tisError,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (afterResult) {\n\t\t\t\tresult = {\n\t\t\t\t\tcontent: afterResult.content ?? result.content,\n\t\t\t\t\tdetails: afterResult.details ?? result.details,\n\t\t\t\t\tterminate: afterResult.terminate ?? result.terminate,\n\t\t\t\t};\n\t\t\t\tisError = afterResult.isError ?? isError;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tresult = createErrorToolResult(error instanceof Error ? error.message : String(error));\n\t\t\tisError = true;\n\t\t}\n\t}\n\n\tif (isError) {\n\t\tresult = appendExecutionFailureTeachNote(result, prepared, executed.errorClass, executionFailureTracker);\n\t} else {\n\t\tresetExecutionFailureTracker(executionFailureTracker);\n\t}\n\n\tconst repaired = appendRepairTeachNotes(result, prepared.toolCall, repairTeachTracker, config);\n\temitToolArgumentValidationTelemetry(\n\t\tconfig,\n\t\tprepared.validationEvent,\n\t\tisError ? \"failed\" : \"succeeded\",\n\t\trepaired.taught ? \"note\" : \"none\",\n\t);\n\n\treturn {\n\t\ttoolCall: prepared.toolCall,\n\t\tresult: repaired.result,\n\t\tisError,\n\t};\n}\n\nfunction createErrorToolResult(message: string): AgentToolResult<any> {\n\treturn {\n\t\tcontent: [{ type: \"text\", text: message }],\n\t\tdetails: {},\n\t};\n}\n\nfunction createErrorToolResultWithGuidance(message: string): AgentToolResult<any> {\n\tconst guidance = getToolExecutionErrorGuidance(message);\n\tif (!guidance) return createErrorToolResult(message);\n\treturn {\n\t\tcontent: [\n\t\t\t{ type: \"text\", text: message },\n\t\t\t{ type: \"text\", text: `[harness] ${guidance}` },\n\t\t],\n\t\tdetails: {},\n\t};\n}\n\nasync function emitToolExecutionStart(toolCall: AgentToolCall, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_start\",\n\t\ttoolCallId: toolCall.id,\n\t\ttoolName: toolCall.name,\n\t\targs: toolCall.arguments,\n\t\trepair: getToolCallRepairInfo(toolCall),\n\t});\n}\n\nfunction getToolCallRepairInfo(toolCall: AgentToolCall): ToolCallRepairInfo | undefined {\n\tif (!toolCall.rawArguments && !toolCall.repairNotes?.length) return undefined;\n\treturn {\n\t\trepaired: true,\n\t\t...(toolCall.rawArguments ? { rawArguments: toolCall.rawArguments } : {}),\n\t\t...(toolCall.repairNotes?.length ? { notes: toolCall.repairNotes } : {}),\n\t};\n}\n\nasync function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_end\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tresult: finalized.result,\n\t\tisError: finalized.isError,\n\t\trepair: getToolCallRepairInfo(finalized.toolCall),\n\t});\n}\n\nfunction createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {\n\treturn {\n\t\trole: \"toolResult\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tcontent: finalized.result.content,\n\t\tdetails: finalized.result.details,\n\t\tisError: finalized.isError,\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nasync function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> {\n\tawait emit({ type: \"message_start\", message: toolResultMessage });\n\tawait emit({ type: \"message_end\", message: toolResultMessage });\n}\n"]}
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAGN,WAAW,EAQX,MAAM,mBAAmB,CAAC;AAY3B,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAIZ,QAAQ,EAER,MAAM,YAAY,CAAC;AAIpB,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAEzE;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAuBzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CA8BzC;AAED,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAgBzB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAiBzB","sourcesContent":["/**\n * Agent loop that works with AgentMessage throughout.\n * Transforms to Message[] only at the LLM call boundary.\n */\n\nimport {\n\ttype AssistantMessage,\n\ttype Context,\n\tEventStream,\n\tformatToolRepairStandingRule,\n\tstreamSimple,\n\ttype ToolArgumentExecutionOutcome,\n\tToolArgumentValidationError,\n\ttype ToolArgumentValidationTelemetryEvent,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"@caupulican/pi-ai\";\nimport {\n\tclassifyToolFailure,\n\tclearToolFailure,\n\tcreateToolFailureMemoryTracker,\n\tcreateToolFailureResult,\n\tnormalizeToolSignature,\n\trememberToolFailure,\n\tsanitizeToolFailureContext,\n\ttype ToolFailureMemoryTracker,\n\ttoolFailureCorrection,\n} from \"./tool-failure-memory.ts\";\nimport type {\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentMessage,\n\tAgentTool,\n\tAgentToolCall,\n\tAgentToolResult,\n\tStreamFn,\n\tToolCallRepairInfo,\n} from \"./types.ts\";\nimport { DEFAULT_MAX_STALL_TURNS } from \"./types.ts\";\nimport { createEmptyUsage } from \"./usage.ts\";\n\nexport type AgentEventSink = (event: AgentEvent) => Promise<void> | void;\n\n/**\n * Start an agent loop with a new prompt message.\n * The prompt is added to the context and events are emitted for it.\n */\nexport function agentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoop(\n\t\tprompts,\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t)\n\t\t.catch(async (error) => {\n\t\t\tconst messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];\n\t\t\tstream.push({ type: \"agent_end\", messages });\n\t\t\treturn messages;\n\t\t})\n\t\t.then((messages) => {\n\t\t\tstream.end(messages);\n\t\t});\n\n\treturn stream;\n}\n\n/**\n * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t)\n\t\t.catch(async (error) => {\n\t\t\tconst messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];\n\t\t\tstream.push({ type: \"agent_end\", messages });\n\t\t\treturn messages;\n\t\t})\n\t\t.then((messages) => {\n\t\t\tstream.end(messages);\n\t\t});\n\n\treturn stream;\n}\n\nexport async function runAgentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tconst newMessages: AgentMessage[] = [...prompts];\n\tconst currentContext: AgentContext = {\n\t\t...context,\n\t\tmessages: [...context.messages, ...prompts],\n\t};\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\tfor (const prompt of prompts) {\n\t\tawait emit({ type: \"message_start\", message: prompt });\n\t\tawait emit({ type: \"message_end\", message: prompt });\n\t}\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nexport async function runAgentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst newMessages: AgentMessage[] = [];\n\tconst currentContext: AgentContext = { ...context, messages: [...context.messages] };\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nfunction createLoopFailureMessage(error: unknown, config: AgentLoopConfig, aborted: boolean): AssistantMessage {\n\treturn {\n\t\trole: \"assistant\",\n\t\tcontent: [{ type: \"text\", text: \"\" }],\n\t\tapi: config.model.api,\n\t\tprovider: config.model.provider,\n\t\tmodel: config.model.id,\n\t\tusage: createEmptyUsage(),\n\t\tstopReason: aborted ? \"aborted\" : \"error\",\n\t\terrorMessage: error instanceof Error ? error.message : String(error),\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nfunction createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {\n\treturn new EventStream<AgentEvent, AgentMessage[]>(\n\t\t(event: AgentEvent) => event.type === \"agent_end\",\n\t\t(event: AgentEvent) => (event.type === \"agent_end\" ? event.messages : []),\n\t);\n}\n\n/**\n * How many `stallLimit`-length periods the runaway-loop window spans. A window of `stallLimit * P`\n * turns lets the count-based detector catch oscillating cycles of period up to `P` (each signature in a\n * period-k cycle recurs ~window/k times), not just back-to-back repeats. Beyond this the cycle is loose\n * enough that it's indistinguishable from legitimate varied work, so we don't chase it.\n */\nconst STALL_WINDOW_PERIODS = 4;\n\n/**\n * Main loop logic shared by agentLoop and agentLoopContinue.\n */\nasync function runLoop(\n\tinitialContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tinitialConfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<void> {\n\tlet currentContext = initialContext;\n\tlet config = initialConfig;\n\tlet firstTurn = true;\n\t// Runaway-loop backstop state: a sliding window of recent NORMALIZED tool-call signatures. A model\n\t// wedged repeating the same action makes no progress but keeps spending tokens; if one signature\n\t// recurs `stallLimit` times within the window we stop gracefully. Signatures are normalized so\n\t// volatile args (timestamps/UUIDs/nonces that change every call) can't disguise an otherwise-\n\t// identical call (bug #28). The window spans `stallLimit * STALL_WINDOW_PERIODS` turns so periodic\n\t// oscillation is caught too, not just back-to-back repeats: a cycle of period P repeats each\n\t// signature ~window/P times, so any P up to STALL_WINDOW_PERIODS reaches the threshold before the\n\t// window slides past it. Counts only turns that issued tool calls, so varied/long work never trips\n\t// it. `0` disables.\n\tconst stallLimit = config.maxStallTurns ?? DEFAULT_MAX_STALL_TURNS;\n\tconst stallWindow: string[] = [];\n\tconst validationFailureTracker: ToolValidationFailureTracker = { repeats: 0 };\n\tconst repairTeachTracker: ToolRepairTeachTracker = new Map();\n\tlet toolFailureMemory = createToolFailureMemoryTracker(currentContext.messages);\n\t// Check for steering messages at start (user may have typed while waiting)\n\tlet pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];\n\n\t// Outer loop: continues when queued follow-up messages arrive after agent would stop\n\twhile (true) {\n\t\tlet hasMoreToolCalls = true;\n\n\t\t// Inner loop: process tool calls and steering messages\n\t\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\t\tif (!firstTurn) {\n\t\t\t\tawait emit({ type: \"turn_start\" });\n\t\t\t} else {\n\t\t\t\tfirstTurn = false;\n\t\t\t}\n\n\t\t\t// Process pending messages (inject before next assistant response)\n\t\t\tif (pendingMessages.length > 0) {\n\t\t\t\tfor (const message of pendingMessages) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message });\n\t\t\t\t\tawait emit({ type: \"message_end\", message });\n\t\t\t\t\tcurrentContext.messages.push(message);\n\t\t\t\t\tnewMessages.push(message);\n\t\t\t\t}\n\t\t\t\tpendingMessages = [];\n\t\t\t}\n\n\t\t\t// Stream assistant response\n\t\t\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\t\t\tnewMessages.push(message);\n\n\t\t\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\t\t\tawait emit({ type: \"turn_end\", message, toolResults: [] });\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for tool calls\n\t\t\tconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n\n\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\thasMoreToolCalls = false;\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tconst executedToolBatch = await executeToolCalls(\n\t\t\t\t\tcurrentContext,\n\t\t\t\t\tmessage,\n\t\t\t\t\tconfig,\n\t\t\t\t\tvalidationFailureTracker,\n\t\t\t\t\trepairTeachTracker,\n\t\t\t\t\ttoolFailureMemory,\n\t\t\t\t\tsignal,\n\t\t\t\t\temit,\n\t\t\t\t);\n\t\t\t\ttoolResults.push(...executedToolBatch.messages);\n\t\t\t\thasMoreToolCalls = !executedToolBatch.terminate;\n\n\t\t\t\tfor (const result of toolResults) {\n\t\t\t\t\tcurrentContext.messages.push(result);\n\t\t\t\t\tnewMessages.push(result);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\n\t\t\t// Runaway-loop backstop (cost guard): detect a model stuck repeating one action.\n\t\t\tif (stallLimit > 0 && toolCalls.length > 0) {\n\t\t\t\tconst signature = normalizeToolSignature(toolCalls.map((c) => [c.name, c.arguments ?? null]));\n\t\t\t\tstallWindow.push(signature);\n\t\t\t\tif (stallWindow.length > stallLimit * STALL_WINDOW_PERIODS) stallWindow.shift();\n\t\t\t\tconst repeats = stallWindow.reduce((n, s) => (s === signature ? n + 1 : n), 0);\n\t\t\t\tif (repeats >= stallLimit) {\n\t\t\t\t\tconfig.onRunawayStop?.({ signature, repeats });\n\t\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst nextTurnContext = {\n\t\t\t\tmessage,\n\t\t\t\ttoolResults,\n\t\t\t\tcontext: currentContext,\n\t\t\t\tnewMessages,\n\t\t\t};\n\t\t\tconst nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);\n\t\t\tif (nextTurnSnapshot) {\n\t\t\t\tcurrentContext = nextTurnSnapshot.context ?? currentContext;\n\t\t\t\tif (nextTurnSnapshot.context) {\n\t\t\t\t\ttoolFailureMemory = createToolFailureMemoryTracker(currentContext.messages);\n\t\t\t\t}\n\t\t\t\tconfig = {\n\t\t\t\t\t...config,\n\t\t\t\t\tmodel: nextTurnSnapshot.model ?? config.model,\n\t\t\t\t\treasoning: nextTurnSnapshot.thinkingLevel ?? config.reasoning,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tawait config.shouldStopAfterTurn?.({\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolResults,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\tnewMessages,\n\t\t\t\t})\n\t\t\t) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpendingMessages = (await config.getSteeringMessages?.()) || [];\n\t\t}\n\n\t\t// Agent would stop here. Check for follow-up messages.\n\t\tconst followUpMessages = (await config.getFollowUpMessages?.()) || [];\n\t\tif (followUpMessages.length > 0) {\n\t\t\t// Set as pending so inner loop processes them\n\t\t\tpendingMessages = followUpMessages;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No more messages, exit\n\t\tbreak;\n\t}\n\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/**\n * Stream an assistant response from the LLM.\n * This is where AgentMessage[] gets transformed to Message[] for the LLM.\n */\nasync function streamAssistantResponse(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantMessage> {\n\t// Failed protocol turns never reach host transforms or provider conversion. Their bounded,\n\t// unresolved state is carried separately in the system prompt until the same operation succeeds.\n\tconst sanitized = sanitizeToolFailureContext(context.messages, context.systemPrompt);\n\tlet messages = sanitized.messages;\n\tif (config.transformContext) {\n\t\tmessages = await config.transformContext(messages, signal);\n\t}\n\t// Convert to LLM-compatible messages (AgentMessage[] → Message[])\n\tconst llmMessages = await config.convertToLlm(messages);\n\n\t// Build LLM context\n\tconst llmContext: Context = {\n\t\tsystemPrompt: sanitized.systemPrompt,\n\t\tmessages: llmMessages,\n\t\ttools: context.tools,\n\t};\n\n\tconst streamFunction = streamFn || streamSimple;\n\n\t// Resolve API key (important for expiring tokens)\n\tconst resolvedApiKey =\n\t\t(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;\n\tconst requestReasoning = config.resolveRequestReasoning\n\t\t? config.resolveRequestReasoning(config.reasoning, {\n\t\t\t\tmodel: config.model,\n\t\t\t\tcontext: llmContext,\n\t\t\t\tmaxTokens: config.maxTokens,\n\t\t\t})\n\t\t: config.reasoning;\n\n\tconst response = await streamFunction(config.model, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\treasoning: requestReasoning,\n\t\tsignal,\n\t});\n\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\n\n\tfor await (const event of response) {\n\t\tswitch (event.type) {\n\t\t\tcase \"start\":\n\t\t\t\tpartialMessage = event.partial;\n\t\t\t\tcontext.messages.push(partialMessage);\n\t\t\t\taddedPartial = true;\n\t\t\t\tawait emit({ type: \"message_start\", message: { ...partialMessage } });\n\t\t\t\tbreak;\n\n\t\t\tcase \"text_start\":\n\t\t\tcase \"text_delta\":\n\t\t\tcase \"text_end\":\n\t\t\tcase \"thinking_start\":\n\t\t\tcase \"thinking_delta\":\n\t\t\tcase \"thinking_end\":\n\t\t\tcase \"toolcall_start\":\n\t\t\tcase \"toolcall_delta\":\n\t\t\tcase \"toolcall_end\":\n\t\t\t\tif (partialMessage) {\n\t\t\t\t\tpartialMessage = event.partial;\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = partialMessage;\n\t\t\t\t\tawait emit({\n\t\t\t\t\t\ttype: \"message_update\",\n\t\t\t\t\t\tassistantMessageEvent: event,\n\t\t\t\t\t\tmessage: { ...partialMessage },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"done\":\n\t\t\tcase \"error\": {\n\t\t\t\tconst finalMessage = await response.result();\n\t\t\t\tif (addedPartial) {\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t\t\t\t} else {\n\t\t\t\t\tcontext.messages.push(finalMessage);\n\t\t\t\t}\n\t\t\t\tif (!addedPartial) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t\t\t\t}\n\t\t\t\tawait emit({ type: \"message_end\", message: finalMessage });\n\t\t\t\treturn finalMessage;\n\t\t\t}\n\t\t}\n\t}\n\n\tconst finalMessage = await response.result();\n\tif (addedPartial) {\n\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t} else {\n\t\tcontext.messages.push(finalMessage);\n\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t}\n\tawait emit({ type: \"message_end\", message: finalMessage });\n\treturn finalMessage;\n}\n\n/**\n * Execute tool calls from an assistant message.\n */\nasync function executeToolCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\ttoolFailureMemory: ToolFailureMemoryTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === \"sequential\",\n\t);\n\tif (config.toolExecution === \"sequential\" || hasSequentialToolCall) {\n\t\treturn executeToolCallsSequential(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCalls,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\trepairTeachTracker,\n\t\t\ttoolFailureMemory,\n\t\t\tsignal,\n\t\t\temit,\n\t\t);\n\t}\n\treturn executeToolCallsParallel(\n\t\tcurrentContext,\n\t\tassistantMessage,\n\t\ttoolCalls,\n\t\tconfig,\n\t\tvalidationFailureTracker,\n\t\trepairTeachTracker,\n\t\ttoolFailureMemory,\n\t\tsignal,\n\t\temit,\n\t);\n}\n\ntype ExecutedToolCallBatch = {\n\tmessages: ToolResultMessage[];\n\tterminate: boolean;\n};\n\nasync function executeToolCallsSequential(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\ttoolFailureMemory: ToolFailureMemoryTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tconst messages: ToolResultMessage[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCall,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\tsignal,\n\t\t);\n\t\tawait emitToolExecutionStart(toolCall, emit);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\temitToolArgumentValidationTelemetry(config, preparation.validationEvent, \"not_run\", \"none\");\n\t\t\tfinalized = finalizeRejectedToolCall(toolCall, preparation, toolFailureMemory);\n\t\t} else {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tfinalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\trepairTeachTracker,\n\t\t\t\ttoolFailureMemory,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t}\n\n\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tfinalizedCalls.push(finalized);\n\t\tmessages.push(toolResultMessage);\n\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(finalizedCalls),\n\t};\n}\n\nasync function executeToolCallsParallel(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\ttoolFailureMemory: ToolFailureMemoryTracker,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tconst preparation = await prepareToolCall(\n\t\t\tcurrentContext,\n\t\t\tassistantMessage,\n\t\t\ttoolCall,\n\t\t\tconfig,\n\t\t\tvalidationFailureTracker,\n\t\t\tsignal,\n\t\t);\n\t\tawait emitToolExecutionStart(toolCall, emit);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\temitToolArgumentValidationTelemetry(config, preparation.validationEvent, \"not_run\", \"none\");\n\t\t\tconst finalized = finalizeRejectedToolCall(toolCall, preparation, toolFailureMemory);\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\tfinalizedCalls.push(finalized);\n\t\t\tif (signal?.aborted) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tfinalizedCalls.push(async () => {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tconst finalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\trepairTeachTracker,\n\t\t\t\ttoolFailureMemory,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\treturn finalized;\n\t\t});\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst orderedFinalizedCalls = await Promise.all(\n\t\tfinalizedCalls.map((entry) => (typeof entry === \"function\" ? entry() : Promise.resolve(entry))),\n\t);\n\tconst messages: ToolResultMessage[] = [];\n\tfor (const finalized of orderedFinalizedCalls) {\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tmessages.push(toolResultMessage);\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(orderedFinalizedCalls),\n\t};\n}\n\ntype PreparedToolCall = {\n\tkind: \"prepared\";\n\ttoolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\targs: unknown;\n\tvalidationEvent?: ToolArgumentValidationTelemetryEvent;\n};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\tfailureCode: string;\n\tcorrection: string;\n\tdiagnostic?: string;\n\tvalidationEvent?: ToolArgumentValidationTelemetryEvent;\n};\n\ntype ExecutedToolCallOutcome = {\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n\terrorClass?: string;\n\tfailureMessage?: string;\n};\n\ntype FinalizedToolCallOutcome = {\n\ttoolCall: AgentToolCall;\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\n\ntype ToolValidationFailureTracker = {\n\tsignature?: string;\n\trepeats: number;\n\tescalatedSignature?: string;\n};\n\ntype ToolRepairTeachTracker = Map<string, number>;\n\nconst DEFAULT_TOOL_VALIDATION_ESCALATION_THRESHOLD = 3;\nconst TOOL_REPAIR_TEACH_EVERY = 5;\n\nfunction shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {\n\treturn finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);\n}\n\nfunction prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {\n\tif (!tool.prepareArguments) {\n\t\treturn toolCall;\n\t}\n\tconst preparedArguments = tool.prepareArguments(toolCall.arguments);\n\tif (preparedArguments === toolCall.arguments) {\n\t\treturn toolCall;\n\t}\n\treturn {\n\t\t...toolCall,\n\t\targuments: preparedArguments as Record<string, any>,\n\t};\n}\n\nfunction createValidationBounceTelemetry(\n\tconfig: AgentLoopConfig,\n\ttoolCall: AgentToolCall,\n\terrorKeyword: string,\n): ToolArgumentValidationTelemetryEvent {\n\treturn {\n\t\toutcome: \"bounced\",\n\t\tprovider: config.model.provider,\n\t\tmodel: config.model.id,\n\t\ttool: toolCall.name,\n\t\tsource: toolCall.source,\n\t\tfailureModes: [\"other\"],\n\t\trepairsApplied: [],\n\t\terrorKeywords: [errorKeyword],\n\t\ttaught: \"none\",\n\t\texecutionOutcome: \"not_run\",\n\t};\n}\n\nfunction validationFailureCorrection(\n\tevent: ToolArgumentValidationTelemetryEvent | undefined,\n\ttoolName: string,\n): string {\n\tconst shape = event?.failureShape\n\t\t?.slice(0, 3)\n\t\t.map((entry) => `${entry.path}: expected ${entry.expectedType}, received ${entry.receivedType}`)\n\t\t.join(\"; \");\n\tconst rules = [\n\t\t...new Set(\n\t\t\t(event?.failureModes ?? [])\n\t\t\t\t.filter((mode) => mode !== \"other\")\n\t\t\t\t.map((mode) => formatToolRepairStandingRule(mode)),\n\t\t),\n\t];\n\treturn [`Match ${toolName} arguments to its current schema.`, shape ? `Fix ${shape}.` : undefined, ...rules]\n\t\t.filter((part): part is string => part !== undefined)\n\t\t.join(\" \");\n}\n\nfunction resetValidationFailureTracker(tracker: ToolValidationFailureTracker): void {\n\ttracker.signature = undefined;\n\ttracker.repeats = 0;\n}\n\nfunction emitToolArgumentValidationTelemetry(\n\tconfig: AgentLoopConfig,\n\tevent: ToolArgumentValidationTelemetryEvent | undefined,\n\texecutionOutcome: ToolArgumentExecutionOutcome,\n\ttaught: ToolArgumentValidationTelemetryEvent[\"taught\"],\n): void {\n\tif (!event) return;\n\tconfig.onToolArgumentValidation?.({ ...event, executionOutcome, taught });\n}\n\nfunction toolValidationEscalationThreshold(config: AgentLoopConfig): number {\n\treturn config.toolValidationEscalationThreshold ?? DEFAULT_TOOL_VALIDATION_ESCALATION_THRESHOLD;\n}\n\nfunction isToolArgumentRepairEmergencyDisabled(): boolean {\n\tconst env = typeof process === \"object\" && process ? process.env : undefined;\n\tconst value = env?.PI_TOOL_REPAIR_DISABLED;\n\tif (!value) return false;\n\treturn [\"1\", \"true\", \"yes\", \"on\"].includes(value.trim().toLowerCase());\n}\n\nfunction isToolArgumentValidationError(error: unknown): error is ToolArgumentValidationError {\n\treturn (\n\t\terror instanceof ToolArgumentValidationError ||\n\t\t(error instanceof Error &&\n\t\t\terror.name === \"ToolArgumentValidationError\" &&\n\t\t\ttypeof (error as { toolName?: unknown }).toolName === \"string\" &&\n\t\t\ttypeof (error as { signature?: unknown }).signature === \"string\" &&\n\t\t\ttypeof (error as { enrichment?: unknown }).enrichment === \"string\")\n\t);\n}\n\nfunction handleValidationFailure(\n\terror: ToolArgumentValidationError,\n\tconfig: AgentLoopConfig,\n\ttracker: ToolValidationFailureTracker,\n): string {\n\tif (tracker.signature === error.signature) {\n\t\ttracker.repeats++;\n\t} else {\n\t\ttracker.signature = error.signature;\n\t\ttracker.repeats = 1;\n\t\ttracker.escalatedSignature = undefined;\n\t}\n\n\tconst threshold = toolValidationEscalationThreshold(config);\n\tif (threshold <= 0 || tracker.repeats < threshold || tracker.escalatedSignature === error.signature) {\n\t\treturn error.message;\n\t}\n\n\ttracker.escalatedSignature = error.signature;\n\tconfig.onToolValidationEscalation?.({\n\t\ttool: error.toolName,\n\t\tsignature: error.signature,\n\t\trepeats: tracker.repeats,\n\t\tmodel: config.model.id,\n\t\tprovider: config.model.provider,\n\t});\n\treturn `${error.message}\\n\\nRepeated validation failure (${tracker.repeats} identical attempts). Use this full schema and example before retrying:\\n${error.enrichment}`;\n}\n\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tvalidationFailureTracker: ToolValidationFailureTracker,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\tif (toolCall.errorMessage) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(toolCall.errorMessage),\n\t\t\tisError: true,\n\t\t\tfailureCode: \"malformed_call\",\n\t\t\tcorrection: \"Resend one complete JSON argument object matching the current tool schema.\",\n\t\t\tvalidationEvent: createValidationBounceTelemetry(config, toolCall, \"unknown_tool\"),\n\t\t};\n\t}\n\n\tconst tool = currentContext.tools?.find((t) => t.name === toolCall.name);\n\tif (!tool) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(`Tool ${toolCall.name} not found`),\n\t\t\tisError: true,\n\t\t\tfailureCode: \"unknown_tool\",\n\t\t\tcorrection: \"Choose a tool from the currently available tool list.\",\n\t\t\tvalidationEvent: createValidationBounceTelemetry(config, toolCall, \"unknown_tool\"),\n\t\t};\n\t}\n\n\tlet validationEvent: ToolArgumentValidationTelemetryEvent | undefined;\n\ttry {\n\t\tconst preparedToolCall = prepareToolCallArguments(tool, toolCall);\n\t\tconst validatedArgs = validateToolArguments(tool, preparedToolCall, {\n\t\t\tmodel: config.model.id,\n\t\t\tprovider: config.model.provider,\n\t\t\trepairEnabled: !isToolArgumentRepairEmergencyDisabled(),\n\t\t\ttelemetry: (event) => {\n\t\t\t\tvalidationEvent = event;\n\t\t\t},\n\t\t});\n\t\tresetValidationFailureTracker(validationFailureTracker);\n\t\tif (preparedToolCall.repairNotes) {\n\t\t\ttoolCall.repairNotes = preparedToolCall.repairNotes;\n\t\t}\n\t\tif (validatedArgs !== toolCall.arguments) {\n\t\t\ttoolCall.rawArguments ??= toolCall.arguments;\n\t\t\ttoolCall.arguments = validatedArgs;\n\t\t}\n\t\tif (config.beforeToolCall) {\n\t\t\tconst beforeResult = await config.beforeToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall,\n\t\t\t\t\targs: validatedArgs,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (signal?.aborted) {\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\t\tisError: true,\n\t\t\t\t\tfailureCode: \"aborted\",\n\t\t\t\t\tcorrection: \"Retry only if the operation is still required.\",\n\t\t\t\t\tvalidationEvent,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (beforeResult?.block) {\n\t\t\t\tconst reason = beforeResult.reason || \"Tool execution was blocked\";\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(reason),\n\t\t\t\t\tisError: true,\n\t\t\t\t\tfailureCode: \"blocked\",\n\t\t\t\t\tcorrection: \"Choose an allowed approach or request the required authority before retrying.\",\n\t\t\t\t\tdiagnostic: reason,\n\t\t\t\t\tvalidationEvent,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t\tif (signal?.aborted) {\n\t\t\treturn {\n\t\t\t\tkind: \"immediate\",\n\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\tisError: true,\n\t\t\t\tfailureCode: \"aborted\",\n\t\t\t\tcorrection: \"Retry only if the operation is still required.\",\n\t\t\t\tvalidationEvent,\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tkind: \"prepared\",\n\t\t\ttoolCall,\n\t\t\ttool,\n\t\t\targs: validatedArgs,\n\t\t\tvalidationEvent,\n\t\t};\n\t} catch (error) {\n\t\tconst message = isToolArgumentValidationError(error)\n\t\t\t? handleValidationFailure(error, config, validationFailureTracker)\n\t\t\t: error instanceof Error\n\t\t\t\t? error.message\n\t\t\t\t: String(error);\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(message),\n\t\t\tisError: true,\n\t\t\tfailureCode: isToolArgumentValidationError(error) ? \"invalid_arguments\" : \"preflight_error\",\n\t\t\tcorrection: isToolArgumentValidationError(error)\n\t\t\t\t? validationFailureCorrection(validationEvent, toolCall.name)\n\t\t\t\t: toolFailureCorrection(message, \"rejected\"),\n\t\t\tvalidationEvent,\n\t\t};\n\t}\n}\n\nfunction finalizeRejectedToolCall(\n\ttoolCall: AgentToolCall,\n\toutcome: ImmediateToolCallOutcome,\n\ttracker: ToolFailureMemoryTracker,\n): FinalizedToolCallOutcome {\n\tconst record = rememberToolFailure(\n\t\ttracker,\n\t\ttoolCall.name,\n\t\ttoolCall.arguments,\n\t\t\"rejected\",\n\t\toutcome.failureCode,\n\t\toutcome.correction,\n\t\toutcome.diagnostic,\n\t);\n\treturn {\n\t\ttoolCall,\n\t\tresult: createToolFailureResult(record, outcome.result.terminate),\n\t\tisError: true,\n\t};\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tconst updateEvents: Promise<void>[] = [];\n\n\ttry {\n\t\tconst result = await prepared.tool.execute(\n\t\t\tprepared.toolCall.id,\n\t\t\tprepared.args as never,\n\t\t\tsignal,\n\t\t\t(partialResult) => {\n\t\t\t\tupdateEvents.push(\n\t\t\t\t\tPromise.resolve(\n\t\t\t\t\t\temit({\n\t\t\t\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\t\t\t\targs: prepared.toolCall.arguments,\n\t\t\t\t\t\t\tpartialResult,\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t},\n\t\t);\n\t\tawait Promise.all(updateEvents);\n\t\treturn { result, isError: false };\n\t} catch (error) {\n\t\tawait Promise.all(updateEvents);\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\treturn {\n\t\t\tresult: createErrorToolResult(message),\n\t\t\tisError: true,\n\t\t\terrorClass: error instanceof Error ? error.name : typeof error,\n\t\t\tfailureMessage: message,\n\t\t};\n\t}\n}\n\nfunction repairTeachKey(toolName: string, note: string): string {\n\tconst repairName = /^\\[harness\\] ([^:]+):/.exec(note)?.[1] ?? note;\n\treturn `${toolName}\\0${repairName}`;\n}\n\nfunction shouldEmitRepairTeachNote(toolName: string, note: string, tracker: ToolRepairTeachTracker): boolean {\n\tconst key = repairTeachKey(toolName, note);\n\tconst count = (tracker.get(key) ?? 0) + 1;\n\ttracker.set(key, count);\n\treturn count === 1 || count % TOOL_REPAIR_TEACH_EVERY === 0;\n}\n\nfunction appendRepairTeachNotes(\n\tresult: AgentToolResult<any>,\n\ttoolCall: AgentToolCall,\n\ttracker: ToolRepairTeachTracker,\n\tconfig: AgentLoopConfig,\n): { result: AgentToolResult<any>; taught: boolean } {\n\tif (config.toolArgumentTeachEnabled === false) return { result, taught: false };\n\tconst notes = (toolCall.repairNotes ?? []).filter((note) => shouldEmitRepairTeachNote(toolCall.name, note, tracker));\n\tif (notes.length === 0) return { result, taught: false };\n\treturn {\n\t\tresult: {\n\t\t\t...result,\n\t\t\tcontent: [...result.content, { type: \"text\", text: notes.join(\"\\n\") }],\n\t\t},\n\t\ttaught: true,\n\t};\n}\n\nasync function finalizeExecutedToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tprepared: PreparedToolCall,\n\texecuted: ExecutedToolCallOutcome,\n\tconfig: AgentLoopConfig,\n\trepairTeachTracker: ToolRepairTeachTracker,\n\ttoolFailureMemory: ToolFailureMemoryTracker,\n\tsignal: AbortSignal | undefined,\n): Promise<FinalizedToolCallOutcome> {\n\tlet result = executed.result;\n\tlet isError = executed.isError;\n\tlet failureMessage = executed.failureMessage ?? \"\";\n\tlet errorClass = executed.errorClass;\n\n\tif (config.afterToolCall) {\n\t\ttry {\n\t\t\tconst afterResult = await config.afterToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall: prepared.toolCall,\n\t\t\t\t\targs: prepared.args,\n\t\t\t\t\tresult,\n\t\t\t\t\tisError,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (afterResult) {\n\t\t\t\tresult = {\n\t\t\t\t\tcontent: afterResult.content ?? result.content,\n\t\t\t\t\tdetails: afterResult.details ?? result.details,\n\t\t\t\t\tusage: afterResult.usage ?? result.usage,\n\t\t\t\t\tterminate: afterResult.terminate ?? result.terminate,\n\t\t\t\t};\n\t\t\t\tisError = afterResult.isError ?? isError;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tfailureMessage = error instanceof Error ? error.message : String(error);\n\t\t\terrorClass = error instanceof Error ? error.name : typeof error;\n\t\t\tresult = { ...createErrorToolResult(failureMessage), usage: result.usage };\n\t\t\tisError = true;\n\t\t}\n\t}\n\n\tif (isError) {\n\t\tconst usage = result.usage;\n\t\tconst record = rememberToolFailure(\n\t\t\ttoolFailureMemory,\n\t\t\tprepared.toolCall.name,\n\t\t\tprepared.args,\n\t\t\t\"failed\",\n\t\t\tclassifyToolFailure(failureMessage, errorClass),\n\t\t\ttoolFailureCorrection(failureMessage, \"failed\"),\n\t\t);\n\t\tresult = { ...createToolFailureResult(record, result.terminate), usage };\n\t} else {\n\t\tclearToolFailure(toolFailureMemory, prepared.toolCall.name, prepared.args);\n\t}\n\n\tconst repaired = isError\n\t\t? { result, taught: false }\n\t\t: appendRepairTeachNotes(result, prepared.toolCall, repairTeachTracker, config);\n\temitToolArgumentValidationTelemetry(\n\t\tconfig,\n\t\tprepared.validationEvent,\n\t\tisError ? \"failed\" : \"succeeded\",\n\t\trepaired.taught ? \"note\" : \"none\",\n\t);\n\n\treturn {\n\t\ttoolCall: prepared.toolCall,\n\t\tresult: repaired.result,\n\t\tisError,\n\t};\n}\n\nfunction createErrorToolResult(message: string): AgentToolResult<any> {\n\treturn {\n\t\tcontent: [{ type: \"text\", text: message }],\n\t\tdetails: {},\n\t};\n}\n\nasync function emitToolExecutionStart(toolCall: AgentToolCall, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_start\",\n\t\ttoolCallId: toolCall.id,\n\t\ttoolName: toolCall.name,\n\t\targs: toolCall.arguments,\n\t\trepair: getToolCallRepairInfo(toolCall),\n\t});\n}\n\nfunction getToolCallRepairInfo(toolCall: AgentToolCall): ToolCallRepairInfo | undefined {\n\tif (!toolCall.rawArguments && !toolCall.repairNotes?.length) return undefined;\n\treturn {\n\t\trepaired: true,\n\t\t...(toolCall.rawArguments ? { rawArguments: toolCall.rawArguments } : {}),\n\t\t...(toolCall.repairNotes?.length ? { notes: toolCall.repairNotes } : {}),\n\t};\n}\n\nasync function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_end\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tresult: finalized.result,\n\t\tisError: finalized.isError,\n\t\trepair: getToolCallRepairInfo(finalized.toolCall),\n\t});\n}\n\nfunction createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {\n\treturn {\n\t\trole: \"toolResult\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tcontent: finalized.result.content,\n\t\tdetails: finalized.result.details,\n\t\tusage: finalized.result.usage,\n\t\tisError: finalized.isError,\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nasync function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> {\n\tawait emit({ type: \"message_start\", message: toolResultMessage });\n\tawait emit({ type: \"message_end\", message: toolResultMessage });\n}\n"]}
@@ -2,7 +2,8 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { EventStream, getToolExecutionErrorGuidance, streamSimple, ToolArgumentValidationError, validateToolArguments, } from "@caupulican/pi-ai";
5
+ import { EventStream, formatToolRepairStandingRule, streamSimple, ToolArgumentValidationError, validateToolArguments, } from "@caupulican/pi-ai";
6
+ import { classifyToolFailure, clearToolFailure, createToolFailureMemoryTracker, createToolFailureResult, normalizeToolSignature, rememberToolFailure, sanitizeToolFailureContext, toolFailureCorrection, } from "./tool-failure-memory.js";
6
7
  import { DEFAULT_MAX_STALL_TURNS } from "./types.js";
7
8
  import { createEmptyUsage } from "./usage.js";
8
9
  /**
@@ -105,20 +106,6 @@ function createAgentStream() {
105
106
  * enough that it's indistinguishable from legitimate varied work, so we don't chase it.
106
107
  */
107
108
  const STALL_WINDOW_PERIODS = 4;
108
- /**
109
- * Normalize a tool-call batch into a stable signature for runaway-loop detection. Volatile argument
110
- * tokens — epoch/timestamps, UUIDs, long hashes/nonces — are masked so a model retrying the SAME call
111
- * with a fresh timestamp/id each turn still collapses to one signature and is detected (bug #28). Only
112
- * clearly-volatile patterns are masked: short numbers (`file2.ts`, `line 42`, `count: 3`) are kept so
113
- * genuinely-distinct calls (reading numbered files, different line ranges) are NOT falsely merged.
114
- */
115
- function normalizeToolSignature(pairs) {
116
- return JSON.stringify(pairs)
117
- .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
118
- .replace(/\d{4}-\d{2}-\d{2}[tT][0-9:.]+(?:z|[+-]\d{2}:?\d{2})?/gi, "<ts>")
119
- .replace(/\b[0-9a-f]{16,}\b/gi, "<hex>")
120
- .replace(/\d{10,}/g, "<num>");
121
- }
122
109
  /**
123
110
  * Main loop logic shared by agentLoop and agentLoopContinue.
124
111
  */
@@ -139,7 +126,7 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
139
126
  const stallWindow = [];
140
127
  const validationFailureTracker = { repeats: 0 };
141
128
  const repairTeachTracker = new Map();
142
- const executionFailureTracker = { repeats: 0 };
129
+ let toolFailureMemory = createToolFailureMemoryTracker(currentContext.messages);
143
130
  // Check for steering messages at start (user may have typed while waiting)
144
131
  let pendingMessages = (await config.getSteeringMessages?.()) || [];
145
132
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
@@ -176,7 +163,7 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
176
163
  const toolResults = [];
177
164
  hasMoreToolCalls = false;
178
165
  if (toolCalls.length > 0) {
179
- const executedToolBatch = await executeToolCalls(currentContext, message, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit);
166
+ const executedToolBatch = await executeToolCalls(currentContext, message, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit);
180
167
  toolResults.push(...executedToolBatch.messages);
181
168
  hasMoreToolCalls = !executedToolBatch.terminate;
182
169
  for (const result of toolResults) {
@@ -207,6 +194,9 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
207
194
  const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
208
195
  if (nextTurnSnapshot) {
209
196
  currentContext = nextTurnSnapshot.context ?? currentContext;
197
+ if (nextTurnSnapshot.context) {
198
+ toolFailureMemory = createToolFailureMemoryTracker(currentContext.messages);
199
+ }
210
200
  config = {
211
201
  ...config,
212
202
  model: nextTurnSnapshot.model ?? config.model,
@@ -241,8 +231,10 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
241
231
  * This is where AgentMessage[] gets transformed to Message[] for the LLM.
242
232
  */
243
233
  async function streamAssistantResponse(context, config, signal, emit, streamFn) {
244
- // Apply context transform if configured (AgentMessage[] AgentMessage[])
245
- let messages = context.messages;
234
+ // Failed protocol turns never reach host transforms or provider conversion. Their bounded,
235
+ // unresolved state is carried separately in the system prompt until the same operation succeeds.
236
+ const sanitized = sanitizeToolFailureContext(context.messages, context.systemPrompt);
237
+ let messages = sanitized.messages;
246
238
  if (config.transformContext) {
247
239
  messages = await config.transformContext(messages, signal);
248
240
  }
@@ -250,7 +242,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
250
242
  const llmMessages = await config.convertToLlm(messages);
251
243
  // Build LLM context
252
244
  const llmContext = {
253
- systemPrompt: context.systemPrompt,
245
+ systemPrompt: sanitized.systemPrompt,
254
246
  messages: llmMessages,
255
247
  tools: context.tools,
256
248
  };
@@ -330,15 +322,15 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
330
322
  /**
331
323
  * Execute tool calls from an assistant message.
332
324
  */
333
- async function executeToolCalls(currentContext, assistantMessage, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit) {
325
+ async function executeToolCalls(currentContext, assistantMessage, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit) {
334
326
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
335
327
  const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
336
328
  if (config.toolExecution === "sequential" || hasSequentialToolCall) {
337
- return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit);
329
+ return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit);
338
330
  }
339
- return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit);
331
+ return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit);
340
332
  }
341
- async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit) {
333
+ async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit) {
342
334
  const finalizedCalls = [];
343
335
  const messages = [];
344
336
  for (const toolCall of toolCalls) {
@@ -346,17 +338,12 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
346
338
  await emitToolExecutionStart(toolCall, emit);
347
339
  let finalized;
348
340
  if (preparation.kind === "immediate") {
349
- resetExecutionFailureTracker(executionFailureTracker);
350
341
  emitToolArgumentValidationTelemetry(config, preparation.validationEvent, "not_run", "none");
351
- finalized = {
352
- toolCall,
353
- result: preparation.result,
354
- isError: preparation.isError,
355
- };
342
+ finalized = finalizeRejectedToolCall(toolCall, preparation, toolFailureMemory);
356
343
  }
357
344
  else {
358
345
  const executed = await executePreparedToolCall(preparation, signal, emit);
359
- finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, executionFailureTracker, signal);
346
+ finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, toolFailureMemory, signal);
360
347
  }
361
348
  await emitToolExecutionEnd(finalized, emit);
362
349
  const toolResultMessage = createToolResultMessage(finalized);
@@ -372,19 +359,14 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
372
359
  terminate: shouldTerminateToolBatch(finalizedCalls),
373
360
  };
374
361
  }
375
- async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, executionFailureTracker, signal, emit) {
362
+ async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, signal, emit) {
376
363
  const finalizedCalls = [];
377
364
  for (const toolCall of toolCalls) {
378
365
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, validationFailureTracker, signal);
379
366
  await emitToolExecutionStart(toolCall, emit);
380
367
  if (preparation.kind === "immediate") {
381
- resetExecutionFailureTracker(executionFailureTracker);
382
368
  emitToolArgumentValidationTelemetry(config, preparation.validationEvent, "not_run", "none");
383
- const finalized = {
384
- toolCall,
385
- result: preparation.result,
386
- isError: preparation.isError,
387
- };
369
+ const finalized = finalizeRejectedToolCall(toolCall, preparation, toolFailureMemory);
388
370
  await emitToolExecutionEnd(finalized, emit);
389
371
  finalizedCalls.push(finalized);
390
372
  if (signal?.aborted) {
@@ -394,7 +376,7 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
394
376
  }
395
377
  finalizedCalls.push(async () => {
396
378
  const executed = await executePreparedToolCall(preparation, signal, emit);
397
- const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, executionFailureTracker, signal);
379
+ const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, toolFailureMemory, signal);
398
380
  await emitToolExecutionEnd(finalized, emit);
399
381
  return finalized;
400
382
  });
@@ -416,7 +398,6 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
416
398
  }
417
399
  const DEFAULT_TOOL_VALIDATION_ESCALATION_THRESHOLD = 3;
418
400
  const TOOL_REPAIR_TEACH_EVERY = 5;
419
- const TOOL_EXECUTION_FAILURE_TEACH_AT = 2;
420
401
  function shouldTerminateToolBatch(finalizedCalls) {
421
402
  return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
422
403
  }
@@ -447,6 +428,20 @@ function createValidationBounceTelemetry(config, toolCall, errorKeyword) {
447
428
  executionOutcome: "not_run",
448
429
  };
449
430
  }
431
+ function validationFailureCorrection(event, toolName) {
432
+ const shape = event?.failureShape
433
+ ?.slice(0, 3)
434
+ .map((entry) => `${entry.path}: expected ${entry.expectedType}, received ${entry.receivedType}`)
435
+ .join("; ");
436
+ const rules = [
437
+ ...new Set((event?.failureModes ?? [])
438
+ .filter((mode) => mode !== "other")
439
+ .map((mode) => formatToolRepairStandingRule(mode))),
440
+ ];
441
+ return [`Match ${toolName} arguments to its current schema.`, shape ? `Fix ${shape}.` : undefined, ...rules]
442
+ .filter((part) => part !== undefined)
443
+ .join(" ");
444
+ }
450
445
  function resetValidationFailureTracker(tracker) {
451
446
  tracker.signature = undefined;
452
447
  tracker.repeats = 0;
@@ -503,6 +498,8 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
503
498
  kind: "immediate",
504
499
  result: createErrorToolResult(toolCall.errorMessage),
505
500
  isError: true,
501
+ failureCode: "malformed_call",
502
+ correction: "Resend one complete JSON argument object matching the current tool schema.",
506
503
  validationEvent: createValidationBounceTelemetry(config, toolCall, "unknown_tool"),
507
504
  };
508
505
  }
@@ -512,6 +509,8 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
512
509
  kind: "immediate",
513
510
  result: createErrorToolResult(`Tool ${toolCall.name} not found`),
514
511
  isError: true,
512
+ failureCode: "unknown_tool",
513
+ correction: "Choose a tool from the currently available tool list.",
515
514
  validationEvent: createValidationBounceTelemetry(config, toolCall, "unknown_tool"),
516
515
  };
517
516
  }
@@ -546,14 +545,20 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
546
545
  kind: "immediate",
547
546
  result: createErrorToolResult("Operation aborted"),
548
547
  isError: true,
548
+ failureCode: "aborted",
549
+ correction: "Retry only if the operation is still required.",
549
550
  validationEvent,
550
551
  };
551
552
  }
552
553
  if (beforeResult?.block) {
554
+ const reason = beforeResult.reason || "Tool execution was blocked";
553
555
  return {
554
556
  kind: "immediate",
555
- result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
557
+ result: createErrorToolResult(reason),
556
558
  isError: true,
559
+ failureCode: "blocked",
560
+ correction: "Choose an allowed approach or request the required authority before retrying.",
561
+ diagnostic: reason,
557
562
  validationEvent,
558
563
  };
559
564
  }
@@ -563,6 +568,8 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
563
568
  kind: "immediate",
564
569
  result: createErrorToolResult("Operation aborted"),
565
570
  isError: true,
571
+ failureCode: "aborted",
572
+ correction: "Retry only if the operation is still required.",
566
573
  validationEvent,
567
574
  };
568
575
  }
@@ -584,10 +591,22 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
584
591
  kind: "immediate",
585
592
  result: createErrorToolResult(message),
586
593
  isError: true,
594
+ failureCode: isToolArgumentValidationError(error) ? "invalid_arguments" : "preflight_error",
595
+ correction: isToolArgumentValidationError(error)
596
+ ? validationFailureCorrection(validationEvent, toolCall.name)
597
+ : toolFailureCorrection(message, "rejected"),
587
598
  validationEvent,
588
599
  };
589
600
  }
590
601
  }
602
+ function finalizeRejectedToolCall(toolCall, outcome, tracker) {
603
+ const record = rememberToolFailure(tracker, toolCall.name, toolCall.arguments, "rejected", outcome.failureCode, outcome.correction, outcome.diagnostic);
604
+ return {
605
+ toolCall,
606
+ result: createToolFailureResult(record, outcome.result.terminate),
607
+ isError: true,
608
+ };
609
+ }
591
610
  async function executePreparedToolCall(prepared, signal, emit) {
592
611
  const updateEvents = [];
593
612
  try {
@@ -607,9 +626,10 @@ async function executePreparedToolCall(prepared, signal, emit) {
607
626
  await Promise.all(updateEvents);
608
627
  const message = error instanceof Error ? error.message : String(error);
609
628
  return {
610
- result: createErrorToolResultWithGuidance(message),
629
+ result: createErrorToolResult(message),
611
630
  isError: true,
612
631
  errorClass: error instanceof Error ? error.name : typeof error,
632
+ failureMessage: message,
613
633
  };
614
634
  }
615
635
  }
@@ -637,39 +657,11 @@ function appendRepairTeachNotes(result, toolCall, tracker, config) {
637
657
  taught: true,
638
658
  };
639
659
  }
640
- function resetExecutionFailureTracker(tracker) {
641
- tracker.signature = undefined;
642
- tracker.repeats = 0;
643
- }
644
- function executionFailureSignature(prepared, errorClass) {
645
- return `${normalizeToolSignature([[prepared.toolCall.name, prepared.args]])}\0${errorClass}`;
646
- }
647
- function appendExecutionFailureTeachNote(result, prepared, errorClass, tracker) {
648
- const signature = executionFailureSignature(prepared, errorClass ?? "tool-error");
649
- if (tracker.signature === signature) {
650
- tracker.repeats++;
651
- }
652
- else {
653
- tracker.signature = signature;
654
- tracker.repeats = 1;
655
- }
656
- if (tracker.repeats !== TOOL_EXECUTION_FAILURE_TEACH_AT || tracker.taughtSignature === signature)
657
- return result;
658
- tracker.taughtSignature = signature;
659
- return {
660
- ...result,
661
- content: [
662
- {
663
- type: "text",
664
- text: `[harness] This exact ${prepared.toolCall.name} call failed twice with the same ${errorClass ?? "tool"} error. Change the arguments or approach; do not resend the identical call.`,
665
- },
666
- ...result.content,
667
- ],
668
- };
669
- }
670
- async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, repairTeachTracker, executionFailureTracker, signal) {
660
+ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, repairTeachTracker, toolFailureMemory, signal) {
671
661
  let result = executed.result;
672
662
  let isError = executed.isError;
663
+ let failureMessage = executed.failureMessage ?? "";
664
+ let errorClass = executed.errorClass;
673
665
  if (config.afterToolCall) {
674
666
  try {
675
667
  const afterResult = await config.afterToolCall({
@@ -684,23 +676,30 @@ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepar
684
676
  result = {
685
677
  content: afterResult.content ?? result.content,
686
678
  details: afterResult.details ?? result.details,
679
+ usage: afterResult.usage ?? result.usage,
687
680
  terminate: afterResult.terminate ?? result.terminate,
688
681
  };
689
682
  isError = afterResult.isError ?? isError;
690
683
  }
691
684
  }
692
685
  catch (error) {
693
- result = createErrorToolResult(error instanceof Error ? error.message : String(error));
686
+ failureMessage = error instanceof Error ? error.message : String(error);
687
+ errorClass = error instanceof Error ? error.name : typeof error;
688
+ result = { ...createErrorToolResult(failureMessage), usage: result.usage };
694
689
  isError = true;
695
690
  }
696
691
  }
697
692
  if (isError) {
698
- result = appendExecutionFailureTeachNote(result, prepared, executed.errorClass, executionFailureTracker);
693
+ const usage = result.usage;
694
+ const record = rememberToolFailure(toolFailureMemory, prepared.toolCall.name, prepared.args, "failed", classifyToolFailure(failureMessage, errorClass), toolFailureCorrection(failureMessage, "failed"));
695
+ result = { ...createToolFailureResult(record, result.terminate), usage };
699
696
  }
700
697
  else {
701
- resetExecutionFailureTracker(executionFailureTracker);
698
+ clearToolFailure(toolFailureMemory, prepared.toolCall.name, prepared.args);
702
699
  }
703
- const repaired = appendRepairTeachNotes(result, prepared.toolCall, repairTeachTracker, config);
700
+ const repaired = isError
701
+ ? { result, taught: false }
702
+ : appendRepairTeachNotes(result, prepared.toolCall, repairTeachTracker, config);
704
703
  emitToolArgumentValidationTelemetry(config, prepared.validationEvent, isError ? "failed" : "succeeded", repaired.taught ? "note" : "none");
705
704
  return {
706
705
  toolCall: prepared.toolCall,
@@ -714,18 +713,6 @@ function createErrorToolResult(message) {
714
713
  details: {},
715
714
  };
716
715
  }
717
- function createErrorToolResultWithGuidance(message) {
718
- const guidance = getToolExecutionErrorGuidance(message);
719
- if (!guidance)
720
- return createErrorToolResult(message);
721
- return {
722
- content: [
723
- { type: "text", text: message },
724
- { type: "text", text: `[harness] ${guidance}` },
725
- ],
726
- details: {},
727
- };
728
- }
729
716
  async function emitToolExecutionStart(toolCall, emit) {
730
717
  await emit({
731
718
  type: "tool_execution_start",
@@ -761,6 +748,7 @@ function createToolResultMessage(finalized) {
761
748
  toolName: finalized.toolCall.name,
762
749
  content: finalized.result.content,
763
750
  details: finalized.result.details,
751
+ usage: finalized.result.usage,
764
752
  isError: finalized.isError,
765
753
  timestamp: Date.now(),
766
754
  };