@fleetagent/pi-agent-core 0.1.9 → 0.2.1

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 (86) hide show
  1. package/README.md +4 -4
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +155 -141
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +3 -0
  7. package/dist/agent.js.map +1 -1
  8. package/dist/harness/agent-harness.d.ts +15 -29
  9. package/dist/harness/agent-harness.d.ts.map +1 -1
  10. package/dist/harness/agent-harness.js +95 -101
  11. package/dist/harness/agent-harness.js.map +1 -1
  12. package/dist/harness/compaction/branch-summarization.d.ts +3 -8
  13. package/dist/harness/compaction/branch-summarization.d.ts.map +1 -1
  14. package/dist/harness/compaction/branch-summarization.js +43 -31
  15. package/dist/harness/compaction/branch-summarization.js.map +1 -1
  16. package/dist/harness/compaction/compaction.d.ts +5 -20
  17. package/dist/harness/compaction/compaction.d.ts.map +1 -1
  18. package/dist/harness/compaction/compaction.js +137 -131
  19. package/dist/harness/compaction/compaction.js.map +1 -1
  20. package/dist/harness/compaction/utils.d.ts +5 -4
  21. package/dist/harness/compaction/utils.d.ts.map +1 -1
  22. package/dist/harness/compaction/utils.js +93 -71
  23. package/dist/harness/compaction/utils.js.map +1 -1
  24. package/dist/harness/env/nodejs.d.ts +17 -33
  25. package/dist/harness/env/nodejs.d.ts.map +1 -1
  26. package/dist/harness/env/nodejs.js +71 -30
  27. package/dist/harness/env/nodejs.js.map +1 -1
  28. package/dist/harness/prompt-templates.d.ts +24 -16
  29. package/dist/harness/prompt-templates.d.ts.map +1 -1
  30. package/dist/harness/prompt-templates.js +14 -25
  31. package/dist/harness/prompt-templates.js.map +1 -1
  32. package/dist/harness/session/jsonl-errors.d.ts +34 -0
  33. package/dist/harness/session/jsonl-errors.d.ts.map +1 -0
  34. package/dist/harness/session/jsonl-errors.js +35 -0
  35. package/dist/harness/session/jsonl-errors.js.map +1 -0
  36. package/dist/harness/session/jsonl-repo.d.ts +16 -11
  37. package/dist/harness/session/jsonl-repo.d.ts.map +1 -1
  38. package/dist/harness/session/jsonl-repo.js +140 -42
  39. package/dist/harness/session/jsonl-repo.js.map +1 -1
  40. package/dist/harness/session/jsonl-storage.d.ts +29 -12
  41. package/dist/harness/session/jsonl-storage.d.ts.map +1 -1
  42. package/dist/harness/session/jsonl-storage.js +528 -65
  43. package/dist/harness/session/jsonl-storage.js.map +1 -1
  44. package/dist/harness/session/memory-repo.d.ts +3 -9
  45. package/dist/harness/session/memory-repo.d.ts.map +1 -1
  46. package/dist/harness/session/memory-repo.js +1 -1
  47. package/dist/harness/session/memory-repo.js.map +1 -1
  48. package/dist/harness/session/memory-storage.d.ts +8 -8
  49. package/dist/harness/session/memory-storage.d.ts.map +1 -1
  50. package/dist/harness/session/memory-storage.js.map +1 -1
  51. package/dist/harness/session/repo-utils.d.ts +2 -5
  52. package/dist/harness/session/repo-utils.d.ts.map +1 -1
  53. package/dist/harness/session/repo-utils.js.map +1 -1
  54. package/dist/harness/session/session.d.ts +2 -6
  55. package/dist/harness/session/session.d.ts.map +1 -1
  56. package/dist/harness/session/session.js +59 -46
  57. package/dist/harness/session/session.js.map +1 -1
  58. package/dist/harness/skills.d.ts +26 -16
  59. package/dist/harness/skills.d.ts.map +1 -1
  60. package/dist/harness/skills.js +62 -55
  61. package/dist/harness/skills.js.map +1 -1
  62. package/dist/harness/types.d.ts +97 -78
  63. package/dist/harness/types.d.ts.map +1 -1
  64. package/dist/harness/types.js.map +1 -1
  65. package/dist/harness/utils/shell-output.d.ts.map +1 -1
  66. package/dist/harness/utils/shell-output.js +27 -18
  67. package/dist/harness/utils/shell-output.js.map +1 -1
  68. package/dist/harness/utils/truncate.d.ts +8 -5
  69. package/dist/harness/utils/truncate.d.ts.map +1 -1
  70. package/dist/harness/utils/truncate.js +68 -75
  71. package/dist/harness/utils/truncate.js.map +1 -1
  72. package/dist/index.d.ts +4 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +3 -1
  75. package/dist/index.js.map +1 -1
  76. package/dist/node.d.ts +1 -1
  77. package/dist/node.d.ts.map +1 -1
  78. package/dist/node.js.map +1 -1
  79. package/dist/proxy.d.ts +3 -3
  80. package/dist/proxy.d.ts.map +1 -1
  81. package/dist/proxy.js +110 -104
  82. package/dist/proxy.js.map +1 -1
  83. package/dist/types.d.ts +14 -5
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/types.js.map +1 -1
  86. package/package.json +3 -3
package/README.md CHANGED
@@ -108,9 +108,9 @@ In parallel mode, tool completion events follow tool completion order, but persi
108
108
 
109
109
  The mode can be set globally via `toolExecution` in the agent config, or per-tool via `executionMode` on `AgentTool`. If any tool call in a batch targets a tool with `executionMode: "sequential"`, the entire batch executes sequentially regardless of the global setting.
110
110
 
111
- The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted.
111
+ The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution and attach `terminate: true` to the blocked result. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted.
112
112
 
113
- Tools can also return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
113
+ Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
114
114
 
115
115
  Low-level loop callers can set `shouldStopAfterTurn` to stop gracefully after the current turn completes:
116
116
 
@@ -196,7 +196,7 @@ const agent = new Agent({
196
196
  // Preflight each tool call after args are validated. Can block execution.
197
197
  beforeToolCall: async ({ toolCall, args, context }) => {
198
198
  if (toolCall.name === "bash") {
199
- return { block: true, reason: "bash is disabled" };
199
+ return { block: true, reason: "bash is disabled", terminate: true };
200
200
  }
201
201
  },
202
202
 
@@ -429,7 +429,7 @@ execute: async (toolCallId, params, signal, onUpdate) => {
429
429
 
430
430
  Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
431
431
 
432
- 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.
432
+ Return `terminate: true` from `execute()`, a blocked `beforeToolCall`, 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
433
 
434
434
  ## Proxy Usage
435
435
 
@@ -1 +1 @@
1
- {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAGN,WAAW,EAIX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAIZ,QAAQ,EACR,MAAM,YAAY,CAAC;AAEpB,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,CAUzC;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,CAkBzC;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\tstreamSimple,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"@fleetagent/pi-ai\";\nimport type {\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentMessage,\n\tAgentTool,\n\tAgentToolCall,\n\tAgentToolResult,\n\tStreamFn,\n} from \"./types.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\tconst state: DetachedAgentStreamState = { messages: [] };\n\n\tvoid runAgentLoop(prompts, context, config, createDetachedEventSink(stream, state), signal, streamFn).then(\n\t\t(messages) => stream.end(messages),\n\t\t(error: unknown) => terminateDetachedAgentStream(stream, state, config, error),\n\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\tconst state: DetachedAgentStreamState = { messages: [] };\n\n\tvoid runAgentLoopContinue(context, config, createDetachedEventSink(stream, state), signal, streamFn).then(\n\t\t(messages) => stream.end(messages),\n\t\t(error: unknown) => terminateDetachedAgentStream(stream, state, config, error),\n\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 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\ninterface DetachedAgentStreamState {\n\tmessages: AgentMessage[];\n\tactiveAssistant?: AssistantMessage;\n\tturnOpen?: boolean;\n}\n\nfunction createDetachedEventSink(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tstate: DetachedAgentStreamState,\n): AgentEventSink {\n\treturn (event) => {\n\t\tif (event.type === \"turn_start\") {\n\t\t\tstate.turnOpen = true;\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tstate.turnOpen = false;\n\t\t} else if (event.type === \"message_start\" && event.message.role === \"assistant\") {\n\t\t\tstate.activeAssistant = event.message;\n\t\t} else if (event.type === \"message_update\" && event.message.role === \"assistant\") {\n\t\t\tstate.activeAssistant = event.message;\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tstate.messages.push(event.message);\n\t\t\tif (event.message.role === \"assistant\") state.activeAssistant = undefined;\n\t\t}\n\t\tstream.push(event);\n\t};\n}\n\nfunction terminateDetachedAgentStream(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tstate: DetachedAgentStreamState,\n\tconfig: AgentLoopConfig,\n\terror: unknown,\n): void {\n\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\tconst message: AssistantMessage = state.activeAssistant\n\t\t? { ...state.activeAssistant, stopReason: \"error\", errorMessage }\n\t\t: {\n\t\t\t\trole: \"assistant\",\n\t\t\t\tcontent: [],\n\t\t\t\tapi: config.model.api,\n\t\t\t\tprovider: config.model.provider,\n\t\t\t\tmodel: config.model.id,\n\t\t\t\tusage: {\n\t\t\t\t\tinput: 0,\n\t\t\t\t\toutput: 0,\n\t\t\t\t\tcacheRead: 0,\n\t\t\t\t\tcacheWrite: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t\t},\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terrorMessage,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t};\n\tif (!state.turnOpen) {\n\t\tstream.push({ type: \"turn_start\" });\n\t\tstate.turnOpen = true;\n\t}\n\tstate.messages.push(message);\n\tif (!state.activeAssistant) stream.push({ type: \"message_start\", message });\n\tstream.push({ type: \"message_end\", message });\n\tstream.push({ type: \"turn_end\", message, toolResults: [] });\n\tstate.turnOpen = false;\n\tstream.push({ type: \"agent_end\", messages: [...state.messages] });\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// 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(currentContext, message, config, signal, emit);\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\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\tif (nextTurnSnapshot.context) {\n\t\t\t\t\tcurrentContext = {\n\t\t\t\t\t\t...nextTurnSnapshot.context,\n\t\t\t\t\t\tmessages: [...nextTurnSnapshot.context.messages],\n\t\t\t\t\t};\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:\n\t\t\t\t\t\tnextTurnSnapshot.thinkingLevel === undefined\n\t\t\t\t\t\t\t? config.reasoning\n\t\t\t\t\t\t\t: nextTurnSnapshot.thinkingLevel === \"off\"\n\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t: nextTurnSnapshot.thinkingLevel,\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\n\tconst response = await streamFunction(config.model, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\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\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(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\treturn executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);\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\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\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\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\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\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\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\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};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype ExecutedToolCallOutcome = {\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallOutcome = {\n\ttoolCall: AgentToolCall;\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\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\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\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};\n\t}\n\n\ttry {\n\t\tconst preparedToolCall = prepareToolCallArguments(tool, toolCall);\n\t\tconst validatedArgs = validateToolArguments(tool, preparedToolCall);\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};\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};\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};\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};\n\t} catch (error) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\n\t\t};\n\t}\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tlet updateEvents = Promise.resolve();\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 = updateEvents.then(() =>\n\t\t\t\t\temit({\n\t\t\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\t\t\targs: prepared.toolCall.arguments,\n\t\t\t\t\t\tpartialResult,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t},\n\t\t);\n\t\tawait updateEvents;\n\t\treturn { result, isError: false };\n\t} catch (error) {\n\t\tawait updateEvents;\n\t\treturn {\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\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\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\treturn {\n\t\ttoolCall: prepared.toolCall,\n\t\tresult,\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 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});\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,EAIN,WAAW,EAIX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EAEf,YAAY,EAIZ,QAAQ,EACR,MAAM,YAAY,CAAC;AAEpB,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,CAUzC;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,CAkBzC;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 AssistantMessageEventStream,\n\ttype Context,\n\tEventStream,\n\tstreamSimple,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"@fleetagent/pi-ai\";\nimport type {\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentLoopTurnUpdate,\n\tAgentMessage,\n\tAgentTool,\n\tAgentToolCall,\n\tAgentToolResult,\n\tStreamFn,\n} from \"./types.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\tconst state: DetachedAgentStreamState = { messages: [] };\n\n\tvoid runAgentLoop(prompts, context, config, createDetachedEventSink(stream, state), signal, streamFn).then(\n\t\t(messages) => stream.end(messages),\n\t\t(error: unknown) => terminateDetachedAgentStream(stream, state, config, error),\n\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\tconst state: DetachedAgentStreamState = { messages: [] };\n\n\tvoid runAgentLoopContinue(context, config, createDetachedEventSink(stream, state), signal, streamFn).then(\n\t\t(messages) => stream.end(messages),\n\t\t(error: unknown) => terminateDetachedAgentStream(stream, state, config, error),\n\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 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\ninterface DetachedAgentStreamState {\n\tmessages: AgentMessage[];\n\tactiveAssistant?: AssistantMessage;\n\tturnOpen?: boolean;\n}\n\nfunction createDetachedEventSink(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tstate: DetachedAgentStreamState,\n): AgentEventSink {\n\treturn (event) => {\n\t\tif (event.type === \"turn_start\") {\n\t\t\tstate.turnOpen = true;\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tstate.turnOpen = false;\n\t\t} else if (event.type === \"message_start\" && event.message.role === \"assistant\") {\n\t\t\tstate.activeAssistant = event.message;\n\t\t} else if (event.type === \"message_update\" && event.message.role === \"assistant\") {\n\t\t\tstate.activeAssistant = event.message;\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tstate.messages.push(event.message);\n\t\t\tif (event.message.role === \"assistant\") state.activeAssistant = undefined;\n\t\t}\n\t\tstream.push(event);\n\t};\n}\n\nfunction terminateDetachedAgentStream(\n\tstream: EventStream<AgentEvent, AgentMessage[]>,\n\tstate: DetachedAgentStreamState,\n\tconfig: AgentLoopConfig,\n\terror: unknown,\n): void {\n\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\tconst message: AssistantMessage = state.activeAssistant\n\t\t? { ...state.activeAssistant, stopReason: \"error\", errorMessage }\n\t\t: {\n\t\t\t\trole: \"assistant\",\n\t\t\t\tcontent: [],\n\t\t\t\tapi: config.model.api,\n\t\t\t\tprovider: config.model.provider,\n\t\t\t\tmodel: config.model.id,\n\t\t\t\tusage: {\n\t\t\t\t\tinput: 0,\n\t\t\t\t\toutput: 0,\n\t\t\t\t\tcacheRead: 0,\n\t\t\t\t\tcacheWrite: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t\t},\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terrorMessage,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t};\n\tif (!state.turnOpen) {\n\t\tstream.push({ type: \"turn_start\" });\n\t\tstate.turnOpen = true;\n\t}\n\tstate.messages.push(message);\n\tif (!state.activeAssistant) stream.push({ type: \"message_start\", message });\n\tstream.push({ type: \"message_end\", message });\n\tstream.push({ type: \"turn_end\", message, toolResults: [] });\n\tstate.turnOpen = false;\n\tstream.push({ type: \"agent_end\", messages: [...state.messages] });\n}\n\ninterface AgentLoopRuntimeState {\n\tcontext: AgentContext;\n\tconfig: AgentLoopConfig;\n}\n\ninterface AgentLoopExecution {\n\tnewMessages: AgentMessage[];\n\tsignal: AbortSignal | undefined;\n\temit: AgentEventSink;\n\tstreamFn: StreamFn | undefined;\n}\n\ninterface TurnSequenceResult {\n\tstate: AgentLoopRuntimeState;\n\tfirstTurn: boolean;\n\tterminated: boolean;\n}\n\ninterface AssistantTurnResult {\n\tterminated: boolean;\n\tmessage: AssistantMessage;\n\ttoolResults: ToolResultMessage[];\n\thasMoreToolCalls: boolean;\n}\n\nasync function emitSubsequentTurnStart(firstTurn: boolean, emit: AgentEventSink): Promise<void> {\n\tif (!firstTurn) await emit({ type: \"turn_start\" });\n}\n\nasync function injectPendingMessages(\n\tpendingMessages: AgentMessage[],\n\tcurrentContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\temit: AgentEventSink,\n): Promise<void> {\n\tfor (const message of pendingMessages) {\n\t\tawait emit({ type: \"message_start\", message });\n\t\tawait emit({ type: \"message_end\", message });\n\t\tcurrentContext.messages.push(message);\n\t\tnewMessages.push(message);\n\t}\n}\n\nasync function executeAssistantTurn(\n\tcurrentContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantTurnResult> {\n\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\tnewMessages.push(message);\n\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\tawait emit({ type: \"turn_end\", message, toolResults: [] });\n\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\treturn { terminated: true, message, toolResults: [], hasMoreToolCalls: false };\n\t}\n\n\tconst toolCalls = message.content.filter((content) => content.type === \"toolCall\");\n\tconst toolResults: ToolResultMessage[] = [];\n\tlet hasMoreToolCalls = false;\n\tif (toolCalls.length > 0) {\n\t\tconst executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);\n\t\ttoolResults.push(...executedToolBatch.messages);\n\t\thasMoreToolCalls = !executedToolBatch.terminate;\n\t\tfor (const result of toolResults) {\n\t\t\tcurrentContext.messages.push(result);\n\t\t\tnewMessages.push(result);\n\t\t}\n\t}\n\tawait emit({ type: \"turn_end\", message, toolResults });\n\treturn { terminated: false, message, toolResults, hasMoreToolCalls };\n}\n\nfunction applyNextTurnUpdate(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tupdate: AgentLoopTurnUpdate | undefined,\n): AgentLoopRuntimeState {\n\tif (!update) return { context, config };\n\tconst nextContext = update.context ? { ...update.context, messages: [...update.context.messages] } : context;\n\tconst reasoning =\n\t\tupdate.thinkingLevel === undefined\n\t\t\t? config.reasoning\n\t\t\t: update.thinkingLevel === \"off\"\n\t\t\t\t? undefined\n\t\t\t\t: update.thinkingLevel;\n\treturn {\n\t\tcontext: nextContext,\n\t\tconfig: { ...config, model: update.model ?? config.model, reasoning },\n\t};\n}\n\nasync function prepareFollowingTurn(\n\tturn: AssistantTurnResult,\n\tcontext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tconfig: AgentLoopConfig,\n): Promise<AgentLoopRuntimeState> {\n\tconst update = await config.prepareNextTurn?.({\n\t\tmessage: turn.message,\n\t\ttoolResults: turn.toolResults,\n\t\tcontext,\n\t\tnewMessages,\n\t});\n\treturn applyNextTurnUpdate(context, config, update);\n}\n\nasync function shouldStopAfterCompletedTurn(\n\tturn: AssistantTurnResult,\n\tcontext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tconfig: AgentLoopConfig,\n): Promise<boolean> {\n\treturn (\n\t\t(await config.shouldStopAfterTurn?.({\n\t\t\tmessage: turn.message,\n\t\t\ttoolResults: turn.toolResults,\n\t\t\tcontext,\n\t\t\tnewMessages,\n\t\t})) ?? false\n\t);\n}\n\nasync function runTurnSequence(\n\tinitialState: AgentLoopRuntimeState,\n\tinitialPendingMessages: AgentMessage[],\n\tfirstTurn: boolean,\n\texecution: AgentLoopExecution,\n): Promise<TurnSequenceResult> {\n\tlet state = initialState;\n\tlet pendingMessages = initialPendingMessages;\n\tlet hasMoreToolCalls = true;\n\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\tawait emitSubsequentTurnStart(firstTurn, execution.emit);\n\t\tfirstTurn = false;\n\t\tawait injectPendingMessages(pendingMessages, state.context, execution.newMessages, execution.emit);\n\t\tpendingMessages = [];\n\n\t\tconst turn = await executeAssistantTurn(\n\t\t\tstate.context,\n\t\t\texecution.newMessages,\n\t\t\tstate.config,\n\t\t\texecution.signal,\n\t\t\texecution.emit,\n\t\t\texecution.streamFn,\n\t\t);\n\t\tif (turn.terminated) return { state, firstTurn, terminated: true };\n\t\thasMoreToolCalls = turn.hasMoreToolCalls;\n\t\tstate = await prepareFollowingTurn(turn, state.context, execution.newMessages, state.config);\n\t\tif (await shouldStopAfterCompletedTurn(turn, state.context, execution.newMessages, state.config)) {\n\t\t\tawait execution.emit({ type: \"agent_end\", messages: execution.newMessages });\n\t\t\treturn { state, firstTurn, terminated: true };\n\t\t}\n\t\tpendingMessages = (await state.config.getSteeringMessages?.()) || [];\n\t}\n\treturn { state, firstTurn, terminated: false };\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 state: AgentLoopRuntimeState = { context: initialContext, config: initialConfig };\n\tlet firstTurn = true;\n\tlet pendingMessages: AgentMessage[] = (await state.config.getSteeringMessages?.()) || [];\n\tconst execution: AgentLoopExecution = { newMessages, signal, emit, streamFn };\n\n\twhile (true) {\n\t\tconst sequence = await runTurnSequence(state, pendingMessages, firstTurn, execution);\n\t\tif (sequence.terminated) return;\n\t\tstate = sequence.state;\n\t\tfirstTurn = sequence.firstTurn;\n\n\t\tconst followUpMessages = (await state.config.getFollowUpMessages?.()) || [];\n\t\tif (followUpMessages.length === 0) break;\n\t\tpendingMessages = followUpMessages;\n\t}\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\nasync function finalizeAssistantResponse(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\taddedPartial: boolean,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\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\nasync function consumeAssistantResponse(\n\tresponse: AssistantMessageEventStream,\n\tcontext: AgentContext,\n\temit: AgentEventSink,\n): Promise<AssistantMessage> {\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\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\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\t\t\tcase \"done\":\n\t\t\tcase \"error\":\n\t\t\t\treturn await finalizeAssistantResponse(response, context, addedPartial, emit);\n\t\t}\n\t}\n\treturn await finalizeAssistantResponse(response, context, addedPartial, emit);\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\n\tconst response = await streamFunction(config.model, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\tsignal,\n\t});\n\treturn await consumeAssistantResponse(response, context, emit);\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\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\tconst toolsByName = new Map<string, AgentTool<any>>();\n\tfor (const tool of currentContext.tools ?? []) {\n\t\tif (!toolsByName.has(tool.name)) toolsByName.set(tool.name, tool);\n\t}\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(toolCall) => toolsByName.get(toolCall.name)?.executionMode === \"sequential\",\n\t);\n\tif (config.toolExecution === \"sequential\" || hasSequentialToolCall) {\n\t\treturn executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\treturn executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);\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\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\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\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\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\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\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\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};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype ExecutedToolCallOutcome = {\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallOutcome = {\n\ttoolCall: AgentToolCall;\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\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\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\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};\n\t}\n\n\ttry {\n\t\tconst preparedToolCall = prepareToolCallArguments(tool, toolCall);\n\t\tconst validatedArgs = validateToolArguments(tool, preparedToolCall);\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};\n\t\t\t}\n\t\t\tif (beforeResult?.block) {\n\t\t\t\tconst result = createErrorToolResult(beforeResult.reason || \"Tool execution was blocked\");\n\t\t\t\tif (beforeResult.terminate === true) {\n\t\t\t\t\tresult.terminate = true;\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult,\n\t\t\t\t\tisError: true,\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};\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};\n\t} catch (error) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\n\t\t};\n\t}\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tlet updateEvents = Promise.resolve();\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 = updateEvents.then(() =>\n\t\t\t\t\temit({\n\t\t\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\t\t\targs: prepared.toolCall.arguments,\n\t\t\t\t\t\tpartialResult,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t},\n\t\t);\n\t\tawait updateEvents;\n\t\treturn { result, isError: false };\n\t} catch (error) {\n\t\tawait updateEvents;\n\t\treturn {\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\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\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\treturn {\n\t\ttoolCall: prepared.toolCall,\n\t\tresult,\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 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});\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\t// Untyped tools can omit content; never let nullish values enter history or provider payloads.\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"]}
@@ -121,131 +121,128 @@ function terminateDetachedAgentStream(stream, state, config, error) {
121
121
  state.turnOpen = false;
122
122
  stream.push({ type: "agent_end", messages: [...state.messages] });
123
123
  }
124
+ async function emitSubsequentTurnStart(firstTurn, emit) {
125
+ if (!firstTurn)
126
+ await emit({ type: "turn_start" });
127
+ }
128
+ async function injectPendingMessages(pendingMessages, currentContext, newMessages, emit) {
129
+ for (const message of pendingMessages) {
130
+ await emit({ type: "message_start", message });
131
+ await emit({ type: "message_end", message });
132
+ currentContext.messages.push(message);
133
+ newMessages.push(message);
134
+ }
135
+ }
136
+ async function executeAssistantTurn(currentContext, newMessages, config, signal, emit, streamFn) {
137
+ const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
138
+ newMessages.push(message);
139
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
140
+ await emit({ type: "turn_end", message, toolResults: [] });
141
+ await emit({ type: "agent_end", messages: newMessages });
142
+ return { terminated: true, message, toolResults: [], hasMoreToolCalls: false };
143
+ }
144
+ const toolCalls = message.content.filter((content) => content.type === "toolCall");
145
+ const toolResults = [];
146
+ let hasMoreToolCalls = false;
147
+ if (toolCalls.length > 0) {
148
+ const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
149
+ toolResults.push(...executedToolBatch.messages);
150
+ hasMoreToolCalls = !executedToolBatch.terminate;
151
+ for (const result of toolResults) {
152
+ currentContext.messages.push(result);
153
+ newMessages.push(result);
154
+ }
155
+ }
156
+ await emit({ type: "turn_end", message, toolResults });
157
+ return { terminated: false, message, toolResults, hasMoreToolCalls };
158
+ }
159
+ function applyNextTurnUpdate(context, config, update) {
160
+ if (!update)
161
+ return { context, config };
162
+ const nextContext = update.context ? { ...update.context, messages: [...update.context.messages] } : context;
163
+ const reasoning = update.thinkingLevel === undefined
164
+ ? config.reasoning
165
+ : update.thinkingLevel === "off"
166
+ ? undefined
167
+ : update.thinkingLevel;
168
+ return {
169
+ context: nextContext,
170
+ config: { ...config, model: update.model ?? config.model, reasoning },
171
+ };
172
+ }
173
+ async function prepareFollowingTurn(turn, context, newMessages, config) {
174
+ const update = await config.prepareNextTurn?.({
175
+ message: turn.message,
176
+ toolResults: turn.toolResults,
177
+ context,
178
+ newMessages,
179
+ });
180
+ return applyNextTurnUpdate(context, config, update);
181
+ }
182
+ async function shouldStopAfterCompletedTurn(turn, context, newMessages, config) {
183
+ return ((await config.shouldStopAfterTurn?.({
184
+ message: turn.message,
185
+ toolResults: turn.toolResults,
186
+ context,
187
+ newMessages,
188
+ })) ?? false);
189
+ }
190
+ async function runTurnSequence(initialState, initialPendingMessages, firstTurn, execution) {
191
+ let state = initialState;
192
+ let pendingMessages = initialPendingMessages;
193
+ let hasMoreToolCalls = true;
194
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
195
+ await emitSubsequentTurnStart(firstTurn, execution.emit);
196
+ firstTurn = false;
197
+ await injectPendingMessages(pendingMessages, state.context, execution.newMessages, execution.emit);
198
+ pendingMessages = [];
199
+ const turn = await executeAssistantTurn(state.context, execution.newMessages, state.config, execution.signal, execution.emit, execution.streamFn);
200
+ if (turn.terminated)
201
+ return { state, firstTurn, terminated: true };
202
+ hasMoreToolCalls = turn.hasMoreToolCalls;
203
+ state = await prepareFollowingTurn(turn, state.context, execution.newMessages, state.config);
204
+ if (await shouldStopAfterCompletedTurn(turn, state.context, execution.newMessages, state.config)) {
205
+ await execution.emit({ type: "agent_end", messages: execution.newMessages });
206
+ return { state, firstTurn, terminated: true };
207
+ }
208
+ pendingMessages = (await state.config.getSteeringMessages?.()) || [];
209
+ }
210
+ return { state, firstTurn, terminated: false };
211
+ }
124
212
  /**
125
213
  * Main loop logic shared by agentLoop and agentLoopContinue.
126
214
  */
127
215
  async function runLoop(initialContext, newMessages, initialConfig, signal, emit, streamFn) {
128
- let currentContext = initialContext;
129
- let config = initialConfig;
216
+ let state = { context: initialContext, config: initialConfig };
130
217
  let firstTurn = true;
131
- // Check for steering messages at start (user may have typed while waiting)
132
- let pendingMessages = (await config.getSteeringMessages?.()) || [];
133
- // Outer loop: continues when queued follow-up messages arrive after agent would stop
218
+ let pendingMessages = (await state.config.getSteeringMessages?.()) || [];
219
+ const execution = { newMessages, signal, emit, streamFn };
134
220
  while (true) {
135
- let hasMoreToolCalls = true;
136
- // Inner loop: process tool calls and steering messages
137
- while (hasMoreToolCalls || pendingMessages.length > 0) {
138
- if (!firstTurn) {
139
- await emit({ type: "turn_start" });
140
- }
141
- else {
142
- firstTurn = false;
143
- }
144
- // Process pending messages (inject before next assistant response)
145
- if (pendingMessages.length > 0) {
146
- for (const message of pendingMessages) {
147
- await emit({ type: "message_start", message });
148
- await emit({ type: "message_end", message });
149
- currentContext.messages.push(message);
150
- newMessages.push(message);
151
- }
152
- pendingMessages = [];
153
- }
154
- // Stream assistant response
155
- const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
156
- newMessages.push(message);
157
- if (message.stopReason === "error" || message.stopReason === "aborted") {
158
- await emit({ type: "turn_end", message, toolResults: [] });
159
- await emit({ type: "agent_end", messages: newMessages });
160
- return;
161
- }
162
- // Check for tool calls
163
- const toolCalls = message.content.filter((c) => c.type === "toolCall");
164
- const toolResults = [];
165
- hasMoreToolCalls = false;
166
- if (toolCalls.length > 0) {
167
- const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
168
- toolResults.push(...executedToolBatch.messages);
169
- hasMoreToolCalls = !executedToolBatch.terminate;
170
- for (const result of toolResults) {
171
- currentContext.messages.push(result);
172
- newMessages.push(result);
173
- }
174
- }
175
- await emit({ type: "turn_end", message, toolResults });
176
- const nextTurnContext = {
177
- message,
178
- toolResults,
179
- context: currentContext,
180
- newMessages,
181
- };
182
- const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
183
- if (nextTurnSnapshot) {
184
- if (nextTurnSnapshot.context) {
185
- currentContext = {
186
- ...nextTurnSnapshot.context,
187
- messages: [...nextTurnSnapshot.context.messages],
188
- };
189
- }
190
- config = {
191
- ...config,
192
- model: nextTurnSnapshot.model ?? config.model,
193
- reasoning: nextTurnSnapshot.thinkingLevel === undefined
194
- ? config.reasoning
195
- : nextTurnSnapshot.thinkingLevel === "off"
196
- ? undefined
197
- : nextTurnSnapshot.thinkingLevel,
198
- };
199
- }
200
- if (await config.shouldStopAfterTurn?.({
201
- message,
202
- toolResults,
203
- context: currentContext,
204
- newMessages,
205
- })) {
206
- await emit({ type: "agent_end", messages: newMessages });
207
- return;
208
- }
209
- pendingMessages = (await config.getSteeringMessages?.()) || [];
210
- }
211
- // Agent would stop here. Check for follow-up messages.
212
- const followUpMessages = (await config.getFollowUpMessages?.()) || [];
213
- if (followUpMessages.length > 0) {
214
- // Set as pending so inner loop processes them
215
- pendingMessages = followUpMessages;
216
- continue;
217
- }
218
- // No more messages, exit
219
- break;
221
+ const sequence = await runTurnSequence(state, pendingMessages, firstTurn, execution);
222
+ if (sequence.terminated)
223
+ return;
224
+ state = sequence.state;
225
+ firstTurn = sequence.firstTurn;
226
+ const followUpMessages = (await state.config.getFollowUpMessages?.()) || [];
227
+ if (followUpMessages.length === 0)
228
+ break;
229
+ pendingMessages = followUpMessages;
220
230
  }
221
231
  await emit({ type: "agent_end", messages: newMessages });
222
232
  }
223
- /**
224
- * Stream an assistant response from the LLM.
225
- * This is where AgentMessage[] gets transformed to Message[] for the LLM.
226
- */
227
- async function streamAssistantResponse(context, config, signal, emit, streamFn) {
228
- // Apply context transform if configured (AgentMessage[] → AgentMessage[])
229
- let messages = context.messages;
230
- if (config.transformContext) {
231
- messages = await config.transformContext(messages, signal);
233
+ async function finalizeAssistantResponse(response, context, addedPartial, emit) {
234
+ const finalMessage = await response.result();
235
+ if (addedPartial) {
236
+ context.messages[context.messages.length - 1] = finalMessage;
232
237
  }
233
- // Convert to LLM-compatible messages (AgentMessage[] → Message[])
234
- const llmMessages = await config.convertToLlm(messages);
235
- // Build LLM context
236
- const llmContext = {
237
- systemPrompt: context.systemPrompt,
238
- messages: llmMessages,
239
- tools: context.tools,
240
- };
241
- const streamFunction = streamFn || streamSimple;
242
- // Resolve API key (important for expiring tokens)
243
- const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
244
- const response = await streamFunction(config.model, llmContext, {
245
- ...config,
246
- apiKey: resolvedApiKey,
247
- signal,
248
- });
238
+ else {
239
+ context.messages.push(finalMessage);
240
+ await emit({ type: "message_start", message: { ...finalMessage } });
241
+ }
242
+ await emit({ type: "message_end", message: finalMessage });
243
+ return finalMessage;
244
+ }
245
+ async function consumeAssistantResponse(response, context, emit) {
249
246
  let partialMessage = null;
250
247
  let addedPartial = false;
251
248
  for await (const event of response) {
@@ -276,39 +273,51 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn)
276
273
  }
277
274
  break;
278
275
  case "done":
279
- case "error": {
280
- const finalMessage = await response.result();
281
- if (addedPartial) {
282
- context.messages[context.messages.length - 1] = finalMessage;
283
- }
284
- else {
285
- context.messages.push(finalMessage);
286
- }
287
- if (!addedPartial) {
288
- await emit({ type: "message_start", message: { ...finalMessage } });
289
- }
290
- await emit({ type: "message_end", message: finalMessage });
291
- return finalMessage;
292
- }
276
+ case "error":
277
+ return await finalizeAssistantResponse(response, context, addedPartial, emit);
293
278
  }
294
279
  }
295
- const finalMessage = await response.result();
296
- if (addedPartial) {
297
- context.messages[context.messages.length - 1] = finalMessage;
298
- }
299
- else {
300
- context.messages.push(finalMessage);
301
- await emit({ type: "message_start", message: { ...finalMessage } });
280
+ return await finalizeAssistantResponse(response, context, addedPartial, emit);
281
+ }
282
+ /**
283
+ * Stream an assistant response from the LLM.
284
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
285
+ */
286
+ async function streamAssistantResponse(context, config, signal, emit, streamFn) {
287
+ // Apply context transform if configured (AgentMessage[] → AgentMessage[])
288
+ let messages = context.messages;
289
+ if (config.transformContext) {
290
+ messages = await config.transformContext(messages, signal);
302
291
  }
303
- await emit({ type: "message_end", message: finalMessage });
304
- return finalMessage;
292
+ // Convert to LLM-compatible messages (AgentMessage[] → Message[])
293
+ const llmMessages = await config.convertToLlm(messages);
294
+ // Build LLM context
295
+ const llmContext = {
296
+ systemPrompt: context.systemPrompt,
297
+ messages: llmMessages,
298
+ tools: context.tools,
299
+ };
300
+ const streamFunction = streamFn || streamSimple;
301
+ // Resolve API key (important for expiring tokens)
302
+ const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
303
+ const response = await streamFunction(config.model, llmContext, {
304
+ ...config,
305
+ apiKey: resolvedApiKey,
306
+ signal,
307
+ });
308
+ return await consumeAssistantResponse(response, context, emit);
305
309
  }
306
310
  /**
307
311
  * Execute tool calls from an assistant message.
308
312
  */
309
313
  async function executeToolCalls(currentContext, assistantMessage, config, signal, emit) {
310
314
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
311
- const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
315
+ const toolsByName = new Map();
316
+ for (const tool of currentContext.tools ?? []) {
317
+ if (!toolsByName.has(tool.name))
318
+ toolsByName.set(tool.name, tool);
319
+ }
320
+ const hasSequentialToolCall = toolCalls.some((toolCall) => toolsByName.get(toolCall.name)?.executionMode === "sequential");
312
321
  if (config.toolExecution === "sequential" || hasSequentialToolCall) {
313
322
  return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
314
323
  }
@@ -439,9 +448,13 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
439
448
  };
440
449
  }
441
450
  if (beforeResult?.block) {
451
+ const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
452
+ if (beforeResult.terminate === true) {
453
+ result.terminate = true;
454
+ }
442
455
  return {
443
456
  kind: "immediate",
444
- result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
457
+ result,
445
458
  isError: true,
446
459
  };
447
460
  }
@@ -544,7 +557,8 @@ function createToolResultMessage(finalized) {
544
557
  role: "toolResult",
545
558
  toolCallId: finalized.toolCall.id,
546
559
  toolName: finalized.toolCall.name,
547
- content: finalized.result.content,
560
+ // Untyped tools can omit content; never let nullish values enter history or provider payloads.
561
+ content: finalized.result.content ?? [],
548
562
  details: finalized.result.details,
549
563
  isError: finalized.isError,
550
564
  timestamp: Date.now(),