@caupulican/pi-agent-core 0.81.41 → 0.81.43

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 (93) hide show
  1. package/dist/agent-loop.d.ts +23 -2
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +189 -42
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/agent.d.ts +15 -1
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +9 -38
  8. package/dist/agent.js.map +1 -1
  9. package/dist/compaction/branch-summarization.d.ts +1 -1
  10. package/dist/compaction/branch-summarization.d.ts.map +1 -1
  11. package/dist/compaction/branch-summarization.js +3 -13
  12. package/dist/compaction/branch-summarization.js.map +1 -1
  13. package/dist/compaction/compaction.d.ts +6 -3
  14. package/dist/compaction/compaction.d.ts.map +1 -1
  15. package/dist/compaction/compaction.js +40 -68
  16. package/dist/compaction/compaction.js.map +1 -1
  17. package/dist/compaction/extraction.d.ts.map +1 -1
  18. package/dist/compaction/extraction.js +1 -6
  19. package/dist/compaction/extraction.js.map +1 -1
  20. package/dist/compaction/index.d.ts.map +1 -1
  21. package/dist/compaction/loop.d.ts.map +1 -1
  22. package/dist/compaction/loop.js.map +1 -1
  23. package/dist/compaction/token-budget.d.ts.map +1 -1
  24. package/dist/compaction/token-budget.js +7 -5
  25. package/dist/compaction/token-budget.js.map +1 -1
  26. package/dist/compaction/utils.d.ts +5 -0
  27. package/dist/compaction/utils.d.ts.map +1 -1
  28. package/dist/compaction/utils.js +20 -0
  29. package/dist/compaction/utils.js.map +1 -1
  30. package/dist/compaction/verification.d.ts.map +1 -1
  31. package/dist/compaction/verification.js +6 -5
  32. package/dist/compaction/verification.js.map +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/messages.d.ts.map +1 -1
  35. package/dist/messages.js.map +1 -1
  36. package/dist/node.d.ts.map +1 -1
  37. package/dist/proxy.d.ts +3 -2
  38. package/dist/proxy.d.ts.map +1 -1
  39. package/dist/proxy.js +88 -56
  40. package/dist/proxy.js.map +1 -1
  41. package/dist/reliability/classifier.d.ts.map +1 -1
  42. package/dist/reliability/classifier.js.map +1 -1
  43. package/dist/reliability/index.d.ts.map +1 -1
  44. package/dist/reliability/node.d.ts.map +1 -1
  45. package/dist/reliability/process-tree.d.ts.map +1 -1
  46. package/dist/reliability/process-tree.js.map +1 -1
  47. package/dist/reliability/provider-signatures.d.ts.map +1 -1
  48. package/dist/reliability/retry-controller.d.ts +1 -1
  49. package/dist/reliability/retry-controller.d.ts.map +1 -1
  50. package/dist/reliability/retry-controller.js +2 -7
  51. package/dist/reliability/retry-controller.js.map +1 -1
  52. package/dist/reliability/retry.d.ts.map +1 -1
  53. package/dist/reliability/retry.js.map +1 -1
  54. package/dist/reliability/watchdogs.d.ts.map +1 -1
  55. package/dist/reliability/watchdogs.js +24 -41
  56. package/dist/reliability/watchdogs.js.map +1 -1
  57. package/dist/session/message-retention.d.ts.map +1 -1
  58. package/dist/session/message-retention.js.map +1 -1
  59. package/dist/session/session-manager.d.ts +1 -1
  60. package/dist/session/session-manager.d.ts.map +1 -1
  61. package/dist/session/session-manager.js +12 -22
  62. package/dist/session/session-manager.js.map +1 -1
  63. package/dist/tool-failure-memory.d.ts +6 -0
  64. package/dist/tool-failure-memory.d.ts.map +1 -1
  65. package/dist/tool-failure-memory.js +78 -20
  66. package/dist/tool-failure-memory.js.map +1 -1
  67. package/dist/types.d.ts +73 -1
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js.map +1 -1
  70. package/dist/usage.d.ts +3 -2
  71. package/dist/usage.d.ts.map +1 -1
  72. package/dist/usage.js +2 -10
  73. package/dist/usage.js.map +1 -1
  74. package/dist/utils/paths.d.ts.map +1 -1
  75. package/dist/utils/paths.js.map +1 -1
  76. package/dist/utils/shell-output.d.ts.map +1 -1
  77. package/dist/utils/shell-output.js.map +1 -1
  78. package/dist/utils/streaming-content.d.ts +23 -0
  79. package/dist/utils/streaming-content.d.ts.map +1 -0
  80. package/dist/utils/streaming-content.js +52 -0
  81. package/dist/utils/streaming-content.js.map +1 -0
  82. package/dist/utils/truncate.d.ts.map +1 -1
  83. package/dist/utils/truncate.js +55 -80
  84. package/dist/utils/truncate.js.map +1 -1
  85. package/dist/uuid.d.ts +1 -1
  86. package/dist/uuid.d.ts.map +1 -1
  87. package/dist/uuid.js +1 -1
  88. package/dist/uuid.js.map +1 -1
  89. package/package.json +6 -6
  90. package/dist/utils/streaming-lines.d.ts +0 -20
  91. package/dist/utils/streaming-lines.d.ts.map +0 -1
  92. package/dist/utils/streaming-lines.js +0 -79
  93. package/dist/utils/streaming-lines.js.map +0 -1
@@ -2,8 +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 } from "@caupulican/pi-ai";
6
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.ts";
5
+ import { EventStream } from "@caupulican/pi-ai/event-stream";
6
+ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentToolCall, RequestPreflightContext, RequestPreflightResult, StreamFn, ToolCallRepairInfo } from "./types.ts";
7
7
  export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
8
8
  /**
9
9
  * Start an agent loop with a new prompt message.
@@ -21,4 +21,25 @@ export declare function agentLoop(prompts: AgentMessage[], context: AgentContext
21
21
  export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
22
22
  export declare function runAgentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, signal?: AbortSignal, streamFn?: StreamFn): Promise<AgentMessage[]>;
23
23
  export declare function runAgentLoopContinue(context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, signal?: AbortSignal, streamFn?: StreamFn): Promise<AgentMessage[]>;
24
+ /**
25
+ * Apply one request-local preflight without mutating persistent loop configuration.
26
+ * Shared with isolated tool-free provider calls so every transport boundary has identical
27
+ * validation and non-widening semantics.
28
+ */
29
+ export declare function resolveRequestPreflightMaxTokens(options: {
30
+ requestPreflight?: (context: RequestPreflightContext, signal?: AbortSignal) => RequestPreflightResult | undefined | Promise<RequestPreflightResult | undefined>;
31
+ model: RequestPreflightContext["model"];
32
+ context: RequestPreflightContext["context"];
33
+ maxTokens?: number;
34
+ signal?: AbortSignal;
35
+ }): Promise<number | undefined>;
36
+ /**
37
+ * Start one provider request through the canonical agent-loop boundary.
38
+ *
39
+ * All callers, including host-owned tool-free finalization, receive the same failure-context
40
+ * sanitization, context transformation/conversion, dynamic authentication, request-local reasoning,
41
+ * and request preflight immediately before transport.
42
+ */
43
+ export declare function startAgentProviderRequest(context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, streamFn?: StreamFn): Promise<Awaited<ReturnType<StreamFn>>>;
44
+ export declare function getToolCallRepairInfo(toolCall: AgentToolCall): ToolCallRepairInfo | undefined;
24
45
  //# sourceMappingURL=agent-loop.d.ts.map
@@ -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;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"]}
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAqB7D,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAEZ,aAAa,EAGb,uBAAuB,EACvB,sBAAsB,EACtB,QAAQ,EACR,kBAAkB,EAClB,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,CAEzC;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,CAIzC;AAuBD,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,CAWzB;AAyCD;;;;GAIG;AACH,wBAAsB,gCAAgC,CAAC,OAAO,EAAE;IAC/D,gBAAgB,CAAC,EAAE,CAClB,OAAO,EAAE,uBAAuB,EAChC,MAAM,CAAC,EAAE,WAAW,KAChB,sBAAsB,GAAG,SAAS,GAAG,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC,CAAC;IACtF,KAAK,EAAE,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACxC,OAAO,EAAE,uBAAuB,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAmB9B;AA2JD;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CA8CxC;AAu2BD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,kBAAkB,GAAG,SAAS,CAO7F"}
@@ -2,8 +2,11 @@
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, formatToolRepairStandingRule, streamSimple, ToolArgumentValidationError, validateToolArguments, } from "@caupulican/pi-ai";
6
- import { classifyToolFailure, clearToolFailure, createToolFailureMemoryTracker, createToolFailureResult, normalizeToolSignature, rememberToolFailure, sanitizeToolFailureContext, toolFailureCorrection, } from "./tool-failure-memory.js";
5
+ import { EventStream } from "@caupulican/pi-ai/event-stream";
6
+ import { streamSimple } from "@caupulican/pi-ai/stream";
7
+ import { formatToolRepairStandingRule } from "@caupulican/pi-ai/tool-repair-registry";
8
+ import { ToolArgumentValidationError, validateToolArguments, } from "@caupulican/pi-ai/validation";
9
+ import { assessToolFailure, clearToolFailure, createToolFailureMemoryTracker, createToolFailureResult, normalizeToolSignature, rememberToolFailure, sanitizeToolFailureContext, toolFailureCorrection, } from "./tool-failure-memory.js";
7
10
  import { DEFAULT_MAX_STALL_TURNS } from "./types.js";
8
11
  import { createEmptyUsage } from "./usage.js";
9
12
  /**
@@ -11,19 +14,7 @@ import { createEmptyUsage } from "./usage.js";
11
14
  * The prompt is added to the context and events are emitted for it.
12
15
  */
13
16
  export function agentLoop(prompts, context, config, signal, streamFn) {
14
- const stream = createAgentStream();
15
- void runAgentLoop(prompts, context, config, async (event) => {
16
- stream.push(event);
17
- }, signal, streamFn)
18
- .catch(async (error) => {
19
- const messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];
20
- stream.push({ type: "agent_end", messages });
21
- return messages;
22
- })
23
- .then((messages) => {
24
- stream.end(messages);
25
- });
26
- return stream;
17
+ return streamAgentLoop(config, signal, (emit) => runAgentLoop(prompts, context, config, emit, signal, streamFn));
27
18
  }
28
19
  /**
29
20
  * Continue an agent loop from the current context without adding a new message.
@@ -34,16 +25,14 @@ export function agentLoop(prompts, context, config, signal, streamFn) {
34
25
  * This cannot be validated here since `convertToLlm` is only called once per turn.
35
26
  */
36
27
  export function agentLoopContinue(context, config, signal, streamFn) {
37
- if (context.messages.length === 0) {
38
- throw new Error("Cannot continue: no messages in context");
39
- }
40
- if (context.messages[context.messages.length - 1].role === "assistant") {
41
- throw new Error("Cannot continue from message role: assistant");
42
- }
28
+ assertContinuableContext(context);
29
+ return streamAgentLoop(config, signal, (emit) => runAgentLoopContinue(context, config, emit, signal, streamFn));
30
+ }
31
+ function streamAgentLoop(config, signal, run) {
43
32
  const stream = createAgentStream();
44
- void runAgentLoopContinue(context, config, async (event) => {
33
+ void run(async (event) => {
45
34
  stream.push(event);
46
- }, signal, streamFn)
35
+ })
47
36
  .catch(async (error) => {
48
37
  const messages = [createLoopFailureMessage(error, config, signal?.aborted ?? false)];
49
38
  stream.push({ type: "agent_end", messages });
@@ -70,12 +59,7 @@ export async function runAgentLoop(prompts, context, config, emit, signal, strea
70
59
  return newMessages;
71
60
  }
72
61
  export async function runAgentLoopContinue(context, config, emit, signal, streamFn) {
73
- if (context.messages.length === 0) {
74
- throw new Error("Cannot continue: no messages in context");
75
- }
76
- if (context.messages[context.messages.length - 1].role === "assistant") {
77
- throw new Error("Cannot continue from message role: assistant");
78
- }
62
+ assertContinuableContext(context);
79
63
  const newMessages = [];
80
64
  const currentContext = { ...context, messages: [...context.messages] };
81
65
  await emit({ type: "agent_start" });
@@ -83,6 +67,14 @@ export async function runAgentLoopContinue(context, config, emit, signal, stream
83
67
  await runLoop(currentContext, newMessages, config, signal, emit, streamFn);
84
68
  return newMessages;
85
69
  }
70
+ function assertContinuableContext(context) {
71
+ if (context.messages.length === 0) {
72
+ throw new Error("Cannot continue: no messages in context");
73
+ }
74
+ if (context.messages[context.messages.length - 1].role === "assistant") {
75
+ throw new Error("Cannot continue from message role: assistant");
76
+ }
77
+ }
86
78
  function createLoopFailureMessage(error, config, aborted) {
87
79
  return {
88
80
  role: "assistant",
@@ -106,6 +98,31 @@ function createAgentStream() {
106
98
  * enough that it's indistinguishable from legitimate varied work, so we don't chase it.
107
99
  */
108
100
  const STALL_WINDOW_PERIODS = 4;
101
+ /**
102
+ * Apply one request-local preflight without mutating persistent loop configuration.
103
+ * Shared with isolated tool-free provider calls so every transport boundary has identical
104
+ * validation and non-widening semantics.
105
+ */
106
+ export async function resolveRequestPreflightMaxTokens(options) {
107
+ if (!options.requestPreflight)
108
+ return options.maxTokens;
109
+ if (options.maxTokens !== undefined && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
110
+ throw new TypeError("request maxTokens must be a positive safe integer");
111
+ }
112
+ const preflight = await options.requestPreflight({ model: options.model, context: options.context, maxTokens: options.maxTokens }, options.signal);
113
+ if (preflight?.maxTokens === undefined)
114
+ return options.maxTokens;
115
+ if (!Number.isSafeInteger(preflight.maxTokens) || preflight.maxTokens <= 0) {
116
+ throw new TypeError("requestPreflight.maxTokens must be a positive safe integer");
117
+ }
118
+ const ceilings = [preflight.maxTokens];
119
+ if (options.maxTokens !== undefined)
120
+ ceilings.push(options.maxTokens);
121
+ if (Number.isSafeInteger(options.model.maxTokens) && options.model.maxTokens > 0) {
122
+ ceilings.push(options.model.maxTokens);
123
+ }
124
+ return Math.min(...ceilings);
125
+ }
109
126
  /**
110
127
  * Main loop logic shared by agentLoop and agentLoopContinue.
111
128
  */
@@ -227,10 +244,13 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
227
244
  await emit({ type: "agent_end", messages: newMessages });
228
245
  }
229
246
  /**
230
- * Stream an assistant response from the LLM.
231
- * This is where AgentMessage[] gets transformed to Message[] for the LLM.
247
+ * Start one provider request through the canonical agent-loop boundary.
248
+ *
249
+ * All callers, including host-owned tool-free finalization, receive the same failure-context
250
+ * sanitization, context transformation/conversion, dynamic authentication, request-local reasoning,
251
+ * and request preflight immediately before transport.
232
252
  */
233
- async function streamAssistantResponse(context, config, signal, emit, streamFn) {
253
+ export async function startAgentProviderRequest(context, config, signal, streamFn) {
234
254
  // Failed protocol turns never reach host transforms or provider conversion. Their bounded,
235
255
  // unresolved state is carried separately in the system prompt until the same operation succeeds.
236
256
  const sanitized = sanitizeToolFailureContext(context.messages, context.systemPrompt);
@@ -247,21 +267,37 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
247
267
  tools: context.tools,
248
268
  };
249
269
  const streamFunction = streamFn || streamSimple;
250
- // Resolve API key (important for expiring tokens)
270
+ const requestMaxTokens = await resolveRequestPreflightMaxTokens({
271
+ requestPreflight: config.requestPreflight,
272
+ model: config.model,
273
+ context: llmContext,
274
+ maxTokens: config.maxTokens,
275
+ signal,
276
+ });
277
+ // Resolve credentials only after the request-local authority/budget gate accepts the request.
278
+ // This prevents an already-exhausted background lane from refreshing OAuth/SSO credentials.
251
279
  const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
252
280
  const requestReasoning = config.resolveRequestReasoning
253
281
  ? config.resolveRequestReasoning(config.reasoning, {
254
282
  model: config.model,
255
283
  context: llmContext,
256
- maxTokens: config.maxTokens,
284
+ maxTokens: requestMaxTokens,
257
285
  })
258
286
  : config.reasoning;
259
- const response = await streamFunction(config.model, llmContext, {
287
+ return await streamFunction(config.model, llmContext, {
260
288
  ...config,
261
289
  apiKey: resolvedApiKey,
290
+ maxTokens: requestMaxTokens,
262
291
  reasoning: requestReasoning,
263
292
  signal,
264
293
  });
294
+ }
295
+ /**
296
+ * Stream an assistant response from the LLM.
297
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
298
+ */
299
+ async function streamAssistantResponse(context, config, signal, emit, streamFn) {
300
+ const response = await startAgentProviderRequest(context, config, signal, streamFn);
265
301
  let partialMessage = null;
266
302
  let addedPartial = false;
267
303
  for await (const event of response) {
@@ -342,8 +378,7 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
342
378
  finalized = finalizeRejectedToolCall(toolCall, preparation, toolFailureMemory);
343
379
  }
344
380
  else {
345
- const executed = await executePreparedToolCall(preparation, signal, emit);
346
- finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, toolFailureMemory, signal);
381
+ finalized = await executeAndFinalizePreparedToolCall(currentContext, assistantMessage, preparation, config, repairTeachTracker, toolFailureMemory, signal, emit);
347
382
  }
348
383
  await emitToolExecutionEnd(finalized, emit);
349
384
  const toolResultMessage = createToolResultMessage(finalized);
@@ -375,8 +410,7 @@ async function executeToolCallsParallel(currentContext, assistantMessage, toolCa
375
410
  continue;
376
411
  }
377
412
  finalizedCalls.push(async () => {
378
- const executed = await executePreparedToolCall(preparation, signal, emit);
379
- const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, repairTeachTracker, toolFailureMemory, signal);
413
+ const finalized = await executeAndFinalizePreparedToolCall(currentContext, assistantMessage, preparation, config, repairTeachTracker, toolFailureMemory, signal, emit);
380
414
  await emitToolExecutionEnd(finalized, emit);
381
415
  return finalized;
382
416
  });
@@ -607,6 +641,110 @@ function finalizeRejectedToolCall(toolCall, outcome, tracker) {
607
641
  isError: true,
608
642
  };
609
643
  }
644
+ function createLinkedToolAbort(foregroundSignal) {
645
+ const controller = new AbortController();
646
+ const abortFromForeground = () => controller.abort(foregroundSignal?.reason);
647
+ let linked = false;
648
+ if (foregroundSignal?.aborted) {
649
+ abortFromForeground();
650
+ }
651
+ else if (foregroundSignal) {
652
+ foregroundSignal.addEventListener("abort", abortFromForeground, { once: true });
653
+ linked = true;
654
+ }
655
+ return {
656
+ signal: controller.signal,
657
+ detachForeground: () => {
658
+ if (!linked || !foregroundSignal)
659
+ return;
660
+ foregroundSignal.removeEventListener("abort", abortFromForeground);
661
+ linked = false;
662
+ },
663
+ cancel: () => controller.abort(),
664
+ };
665
+ }
666
+ function getBackgroundToolCallDelay(config) {
667
+ const delay = config.backgroundToolCallAfterMs;
668
+ if (delay === undefined || !Number.isFinite(delay) || delay <= 0)
669
+ return undefined;
670
+ return delay;
671
+ }
672
+ async function executeAndFinalizePreparedToolCall(currentContext, assistantMessage, prepared, config, repairTeachTracker, toolFailureMemory, foregroundSignal, emit) {
673
+ const backgroundDelay = getBackgroundToolCallDelay(config);
674
+ if (!config.handoffToolCall ||
675
+ (backgroundDelay === undefined && config.subscribeToolCallHandoffRequest === undefined)) {
676
+ const executed = await executePreparedToolCall(prepared, foregroundSignal, emit);
677
+ return finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, repairTeachTracker, toolFailureMemory, foregroundSignal);
678
+ }
679
+ const executionAbort = createLinkedToolAbort(foregroundSignal);
680
+ const startedAt = Date.now();
681
+ let emitForegroundUpdates = true;
682
+ const completion = (async () => {
683
+ const executed = await executePreparedToolCall(prepared, executionAbort.signal, (event) => {
684
+ if (emitForegroundUpdates)
685
+ return emit(event);
686
+ });
687
+ return finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, repairTeachTracker, toolFailureMemory, executionAbort.signal);
688
+ })();
689
+ completion.then(executionAbort.detachForeground, executionAbort.detachForeground);
690
+ let deadlineTimer;
691
+ const triggers = [];
692
+ if (backgroundDelay !== undefined) {
693
+ triggers.push(new Promise((resolve) => {
694
+ deadlineTimer = setTimeout(() => resolve({ kind: "deadline" }), backgroundDelay);
695
+ deadlineTimer.unref?.();
696
+ }));
697
+ }
698
+ let unsubscribeHandoffRequest;
699
+ if (config.subscribeToolCallHandoffRequest) {
700
+ const manual = new Promise((resolve) => {
701
+ try {
702
+ unsubscribeHandoffRequest = config.subscribeToolCallHandoffRequest?.(prepared.toolCall.id, () => resolve({ kind: "manual" }));
703
+ }
704
+ catch {
705
+ unsubscribeHandoffRequest = undefined;
706
+ }
707
+ });
708
+ triggers.push(manual);
709
+ }
710
+ let outcome;
711
+ try {
712
+ outcome = await Promise.race([completion.then((value) => ({ kind: "completed", value })), ...triggers]);
713
+ }
714
+ finally {
715
+ if (deadlineTimer !== undefined)
716
+ clearTimeout(deadlineTimer);
717
+ unsubscribeHandoffRequest?.();
718
+ }
719
+ if (outcome.kind === "completed")
720
+ return outcome.value;
721
+ if (executionAbort.signal.aborted)
722
+ return completion;
723
+ let handoff;
724
+ try {
725
+ handoff = config.handoffToolCall({
726
+ assistantMessage,
727
+ toolCall: prepared.toolCall,
728
+ args: prepared.args,
729
+ context: currentContext,
730
+ elapsedMs: Math.max(0, Date.now() - startedAt),
731
+ completion,
732
+ cancel: executionAbort.cancel,
733
+ });
734
+ }
735
+ catch {
736
+ return completion;
737
+ }
738
+ if (!handoff)
739
+ return completion;
740
+ emitForegroundUpdates = false;
741
+ executionAbort.detachForeground();
742
+ return {
743
+ toolCall: prepared.toolCall,
744
+ result: handoff.result,
745
+ isError: handoff.isError ?? handoff.result.isError === true,
746
+ };
747
+ }
610
748
  async function executePreparedToolCall(prepared, signal, emit) {
611
749
  const updateEvents = [];
612
750
  try {
@@ -620,7 +758,14 @@ async function executePreparedToolCall(prepared, signal, emit) {
620
758
  })));
621
759
  });
622
760
  await Promise.all(updateEvents);
623
- return { result, isError: false };
761
+ return {
762
+ result,
763
+ // Tool definitions can report an expected operation failure without
764
+ // throwing. Keep the returned result intact through afterToolCall so
765
+ // policy hooks can inspect its bounded diagnostics and metadata.
766
+ isError: result.isError === true,
767
+ ...(result.isError === true ? { errorClass: "tool_result_error" } : {}),
768
+ };
624
769
  }
625
770
  catch (error) {
626
771
  await Promise.all(updateEvents);
@@ -691,7 +836,9 @@ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepar
691
836
  }
692
837
  if (isError) {
693
838
  const usage = result.usage;
694
- const record = rememberToolFailure(toolFailureMemory, prepared.toolCall.name, prepared.args, "failed", classifyToolFailure(failureMessage, errorClass), toolFailureCorrection(failureMessage, "failed"));
839
+ const effectiveFailureMessage = failureMessage || result.content.find((block) => block.type === "text")?.text || "Tool execution failed";
840
+ const assessment = assessToolFailure(effectiveFailureMessage, "failed", errorClass);
841
+ const record = rememberToolFailure(toolFailureMemory, prepared.toolCall.name, prepared.args, "failed", assessment.failureCode, assessment.guidance, assessment.diagnostic);
695
842
  result = { ...createToolFailureResult(record, result.terminate), usage };
696
843
  }
697
844
  else {
@@ -722,7 +869,7 @@ async function emitToolExecutionStart(toolCall, emit) {
722
869
  repair: getToolCallRepairInfo(toolCall),
723
870
  });
724
871
  }
725
- function getToolCallRepairInfo(toolCall) {
872
+ export function getToolCallRepairInfo(toolCall) {
726
873
  if (!toolCall.rawArguments && !toolCall.repairNotes?.length)
727
874
  return undefined;
728
875
  return {