@fleetagent/pi-agent-core 0.1.8 → 0.2.0
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.
- package/README.md +4 -4
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +155 -141
- package/dist/agent-loop.js.map +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +3 -0
- package/dist/agent.js.map +1 -1
- package/dist/harness/agent-harness.d.ts +15 -29
- package/dist/harness/agent-harness.d.ts.map +1 -1
- package/dist/harness/agent-harness.js +95 -101
- package/dist/harness/agent-harness.js.map +1 -1
- package/dist/harness/compaction/branch-summarization.d.ts +3 -8
- package/dist/harness/compaction/branch-summarization.d.ts.map +1 -1
- package/dist/harness/compaction/branch-summarization.js +43 -31
- package/dist/harness/compaction/branch-summarization.js.map +1 -1
- package/dist/harness/compaction/compaction.d.ts +5 -20
- package/dist/harness/compaction/compaction.d.ts.map +1 -1
- package/dist/harness/compaction/compaction.js +137 -131
- package/dist/harness/compaction/compaction.js.map +1 -1
- package/dist/harness/compaction/utils.d.ts +5 -4
- package/dist/harness/compaction/utils.d.ts.map +1 -1
- package/dist/harness/compaction/utils.js +93 -71
- package/dist/harness/compaction/utils.js.map +1 -1
- package/dist/harness/env/nodejs.d.ts +17 -33
- package/dist/harness/env/nodejs.d.ts.map +1 -1
- package/dist/harness/env/nodejs.js +71 -30
- package/dist/harness/env/nodejs.js.map +1 -1
- package/dist/harness/prompt-templates.d.ts +24 -16
- package/dist/harness/prompt-templates.d.ts.map +1 -1
- package/dist/harness/prompt-templates.js +14 -25
- package/dist/harness/prompt-templates.js.map +1 -1
- package/dist/harness/session/jsonl-errors.d.ts +34 -0
- package/dist/harness/session/jsonl-errors.d.ts.map +1 -0
- package/dist/harness/session/jsonl-errors.js +35 -0
- package/dist/harness/session/jsonl-errors.js.map +1 -0
- package/dist/harness/session/jsonl-repo.d.ts +16 -11
- package/dist/harness/session/jsonl-repo.d.ts.map +1 -1
- package/dist/harness/session/jsonl-repo.js +140 -42
- package/dist/harness/session/jsonl-repo.js.map +1 -1
- package/dist/harness/session/jsonl-storage.d.ts +29 -12
- package/dist/harness/session/jsonl-storage.d.ts.map +1 -1
- package/dist/harness/session/jsonl-storage.js +528 -65
- package/dist/harness/session/jsonl-storage.js.map +1 -1
- package/dist/harness/session/memory-repo.d.ts +3 -9
- package/dist/harness/session/memory-repo.d.ts.map +1 -1
- package/dist/harness/session/memory-repo.js +1 -1
- package/dist/harness/session/memory-repo.js.map +1 -1
- package/dist/harness/session/memory-storage.d.ts +8 -8
- package/dist/harness/session/memory-storage.d.ts.map +1 -1
- package/dist/harness/session/memory-storage.js.map +1 -1
- package/dist/harness/session/repo-utils.d.ts +2 -5
- package/dist/harness/session/repo-utils.d.ts.map +1 -1
- package/dist/harness/session/repo-utils.js.map +1 -1
- package/dist/harness/session/session.d.ts +2 -6
- package/dist/harness/session/session.d.ts.map +1 -1
- package/dist/harness/session/session.js +59 -46
- package/dist/harness/session/session.js.map +1 -1
- package/dist/harness/skills.d.ts +26 -16
- package/dist/harness/skills.d.ts.map +1 -1
- package/dist/harness/skills.js +62 -55
- package/dist/harness/skills.js.map +1 -1
- package/dist/harness/types.d.ts +97 -78
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/utils/shell-output.d.ts.map +1 -1
- package/dist/harness/utils/shell-output.js +27 -18
- package/dist/harness/utils/shell-output.js.map +1 -1
- package/dist/harness/utils/truncate.d.ts +8 -5
- package/dist/harness/utils/truncate.d.ts.map +1 -1
- package/dist/harness/utils/truncate.js +68 -75
- package/dist/harness/utils/truncate.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js.map +1 -1
- package/dist/proxy.d.ts +3 -3
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +110 -104
- package/dist/proxy.js.map +1 -1
- package/dist/types.d.ts +14 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|
|
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
|
|
package/dist/agent-loop.d.ts.map
CHANGED
|
@@ -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"]}
|
package/dist/agent-loop.js
CHANGED
|
@@ -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
|
|
129
|
-
let config = initialConfig;
|
|
216
|
+
let state = { context: initialContext, config: initialConfig };
|
|
130
217
|
let firstTurn = true;
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
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
|
|
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
|
|
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(),
|