@brian-agent/agent 0.0.0-stage → 0.1.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.
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Agent loop that works with AgentMessage throughout.
3
+ * Transforms to Message[] only at the LLM call boundary.
4
+ */
5
+ import { EventStream, getCurrentTools, getToolStateChanges, normalizeContext, toToolDeclaration, validateToolArguments, } from "@brian-agent/ai";
6
+ import { getDefaultStreamFn } from "./stream-fn.js";
7
+ /**
8
+ * Start an agent loop with a new prompt message.
9
+ * The prompt is added to the context and events are emitted for it.
10
+ */
11
+ export function agentLoop(prompts, context, config, signal, streamFn) {
12
+ const stream = createAgentStream();
13
+ void runAgentLoop(prompts, context, config, async (event) => {
14
+ stream.push(event);
15
+ }, signal, streamFn).then((messages) => {
16
+ stream.end(messages);
17
+ });
18
+ return stream;
19
+ }
20
+ /**
21
+ * Continue an agent loop from the current context without adding a new message.
22
+ * Used for retries - context already has user message or tool results.
23
+ *
24
+ * **Important:** The last message in context must convert to a `user` or `toolResult` message
25
+ * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
26
+ * This cannot be validated here since `convertToLlm` is only called once per turn.
27
+ */
28
+ export function agentLoopContinue(context, config, signal, streamFn) {
29
+ if (context.messages.length === 0) {
30
+ throw new Error("Cannot continue: no messages in context");
31
+ }
32
+ if (context.messages[context.messages.length - 1].role === "assistant") {
33
+ throw new Error("Cannot continue from message role: assistant");
34
+ }
35
+ const stream = createAgentStream();
36
+ void runAgentLoopContinue(context, config, async (event) => {
37
+ stream.push(event);
38
+ }, signal, streamFn).then((messages) => {
39
+ stream.end(messages);
40
+ });
41
+ return stream;
42
+ }
43
+ export async function runAgentLoop(prompts, context, config, emit, signal, streamFn) {
44
+ const initialMessages = declareToolChanges(context, prompts);
45
+ const newMessages = [...initialMessages];
46
+ const currentContext = {
47
+ ...context,
48
+ messages: [...context.messages, ...initialMessages],
49
+ };
50
+ await emit({ type: "agent_start" });
51
+ await emit({ type: "turn_start" });
52
+ for (const message of initialMessages) {
53
+ await emit({ type: "message_start", message });
54
+ await emit({ type: "message_end", message });
55
+ }
56
+ await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
57
+ return newMessages;
58
+ }
59
+ export async function runAgentLoopContinue(context, config, emit, signal, streamFn) {
60
+ if (context.messages.length === 0) {
61
+ throw new Error("Cannot continue: no messages in context");
62
+ }
63
+ if (context.messages[context.messages.length - 1].role === "assistant") {
64
+ throw new Error("Cannot continue from message role: assistant");
65
+ }
66
+ const newMessages = [];
67
+ const currentContext = { ...context };
68
+ await emit({ type: "agent_start" });
69
+ await emit({ type: "turn_start" });
70
+ await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
71
+ return newMessages;
72
+ }
73
+ function createAgentStream() {
74
+ return new EventStream((event) => event.type === "agent_end", (event) => (event.type === "agent_end" ? event.messages : []));
75
+ }
76
+ /**
77
+ * Main loop logic shared by agentLoop and agentLoopContinue.
78
+ */
79
+ async function runLoop(initialContext, newMessages, initialConfig, signal, emit, streamFunction) {
80
+ let currentContext = initialContext;
81
+ let config = initialConfig;
82
+ let lastCompletedTurn;
83
+ let explicitContinuation = false;
84
+ // Check for steering messages at start (user may have typed while waiting)
85
+ let pendingMessages = (await config.getSteeringMessages?.()) || [];
86
+ // Outer loop: continues when queued follow-up messages arrive after agent would stop
87
+ while (true) {
88
+ let hasMoreToolCalls = true;
89
+ // Inner loop: process tool calls and steering messages
90
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
91
+ let preparedMessages = [];
92
+ if (lastCompletedTurn) {
93
+ const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
94
+ if (nextTurnSnapshot) {
95
+ currentContext = nextTurnSnapshot.context ?? currentContext;
96
+ preparedMessages = nextTurnSnapshot.messages ?? [];
97
+ config = {
98
+ ...config,
99
+ model: nextTurnSnapshot.model ?? config.model,
100
+ reasoning: nextTurnSnapshot.thinkingLevel === undefined
101
+ ? config.reasoning
102
+ : nextTurnSnapshot.thinkingLevel === "off"
103
+ ? undefined
104
+ : nextTurnSnapshot.thinkingLevel,
105
+ };
106
+ }
107
+ // Preparation can be long-running (for example, compaction). Pick up steering
108
+ // queued while it ran. Only poll again if the earlier poll returned nothing;
109
+ // otherwise one-at-a-time mode would deliver two messages in this turn.
110
+ if (pendingMessages.length === 0) {
111
+ pendingMessages = (await config.getSteeringMessages?.()) || [];
112
+ }
113
+ await emit({ type: "turn_start" });
114
+ }
115
+ // Process prepared and queued messages before the next assistant response.
116
+ for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
117
+ await emit({ type: "message_start", message });
118
+ await emit({ type: "message_end", message });
119
+ currentContext.messages.push(message);
120
+ newMessages.push(message);
121
+ }
122
+ pendingMessages = [];
123
+ const requestUpdate = await config.prepareRequest?.({
124
+ context: currentContext,
125
+ model: config.model,
126
+ thinkingLevel: config.reasoning ?? "off",
127
+ }, signal);
128
+ if (requestUpdate) {
129
+ currentContext = requestUpdate.context ?? currentContext;
130
+ config = {
131
+ ...config,
132
+ model: requestUpdate.model ?? config.model,
133
+ reasoning: requestUpdate.thinkingLevel === undefined
134
+ ? config.reasoning
135
+ : requestUpdate.thinkingLevel === "off"
136
+ ? undefined
137
+ : requestUpdate.thinkingLevel,
138
+ };
139
+ }
140
+ // Stream assistant response
141
+ const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
142
+ newMessages.push(message);
143
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
144
+ lastCompletedTurn = {
145
+ message,
146
+ toolResults: [],
147
+ context: currentContext,
148
+ newMessages,
149
+ };
150
+ await config.finishTurn?.(lastCompletedTurn, signal);
151
+ await emit({ type: "turn_end", message, toolResults: [] });
152
+ await emit({ type: "agent_end", messages: newMessages });
153
+ return;
154
+ }
155
+ // Check for tool calls
156
+ const toolCalls = message.content.filter((c) => c.type === "toolCall");
157
+ const toolResults = [];
158
+ hasMoreToolCalls = false;
159
+ if (toolCalls.length > 0) {
160
+ // A "length" stop means the output was cut off by the token limit, so
161
+ // every tool call in the message may carry truncated arguments. Fail
162
+ // them all instead of executing potentially borked calls.
163
+ const executedToolBatch = message.stopReason === "length"
164
+ ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
165
+ : await executeToolCalls(currentContext, message, config, signal, emit);
166
+ toolResults.push(...executedToolBatch.messages);
167
+ hasMoreToolCalls = !executedToolBatch.terminate;
168
+ for (const result of toolResults) {
169
+ currentContext.messages.push(result);
170
+ newMessages.push(result);
171
+ }
172
+ }
173
+ lastCompletedTurn = {
174
+ message,
175
+ toolResults,
176
+ context: currentContext,
177
+ newMessages,
178
+ };
179
+ const decision = await config.finishTurn?.(lastCompletedTurn, signal);
180
+ await emit({ type: "turn_end", message, toolResults });
181
+ if (decision?.action === "end") {
182
+ await emit({ type: "agent_end", messages: newMessages });
183
+ return;
184
+ }
185
+ explicitContinuation = decision?.action === "continue";
186
+ pendingMessages = (await config.getSteeringMessages?.()) || [];
187
+ if (hasMoreToolCalls || pendingMessages.length > 0) {
188
+ explicitContinuation = false;
189
+ }
190
+ }
191
+ // Agent would stop here. Check for follow-up messages.
192
+ const followUpMessages = (await config.getFollowUpMessages?.()) || [];
193
+ if (followUpMessages.length > 0) {
194
+ // Set as pending so inner loop processes them
195
+ explicitContinuation = false;
196
+ pendingMessages = followUpMessages;
197
+ continue;
198
+ }
199
+ // No natural request was selected, so fulfill the continuation decision with one context-only turn.
200
+ if (explicitContinuation) {
201
+ explicitContinuation = false;
202
+ continue;
203
+ }
204
+ // No more messages, exit
205
+ break;
206
+ }
207
+ await emit({ type: "agent_end", messages: newMessages });
208
+ }
209
+ /**
210
+ * Declare tool loadout changes to the model.
211
+ *
212
+ * `context.tools` is what the runtime can execute; the transcript's system messages declare
213
+ * what the model may call. Before each request the difference becomes `toolsAdded` and
214
+ * `toolsRemoved` on a system message. When a pending system message exists, its tool fields
215
+ * are treated as intent and replaced with the delta between the committed transcript and
216
+ * the executable set, so replay always yields exactly `context.tools`. Otherwise a new
217
+ * system message is inserted before the first non-system pending message.
218
+ */
219
+ function declareToolChanges(context, pendingMessages) {
220
+ let systemIndex = -1;
221
+ for (let i = pendingMessages.length - 1; i >= 0; i--) {
222
+ if (pendingMessages[i].role === "system") {
223
+ systemIndex = i;
224
+ break;
225
+ }
226
+ }
227
+ const pending = pendingMessages[systemIndex];
228
+ const baseline = pending
229
+ ? pendingMessages.map((message, index) => index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message)
230
+ : pendingMessages;
231
+ const changes = getToolStateChanges(getCurrentTools([...context.messages, ...baseline]), (context.tools ?? []).map(toToolDeclaration));
232
+ const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0;
233
+ if (pending) {
234
+ // Keep the caller's message object when it already declares no tool changes.
235
+ if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length)
236
+ return pendingMessages;
237
+ return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message));
238
+ }
239
+ if (unchanged)
240
+ return pendingMessages;
241
+ const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes);
242
+ const insertIndex = pendingMessages.findIndex((message) => message.role !== "system");
243
+ const index = insertIndex === -1 ? pendingMessages.length : insertIndex;
244
+ return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
245
+ }
246
+ const NO_CHANGES = { toolsAdded: [], toolsRemoved: [] };
247
+ /** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */
248
+ function withToolChanges(message, { toolsAdded, toolsRemoved }) {
249
+ const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message;
250
+ return {
251
+ ...rest,
252
+ ...(toolsAdded.length > 0 ? { toolsAdded } : {}),
253
+ ...(toolsRemoved.length > 0 ? { toolsRemoved } : {}),
254
+ };
255
+ }
256
+ /**
257
+ * Stream an assistant response from the LLM.
258
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
259
+ */
260
+ async function streamAssistantResponse(context, config, signal, emit, streamFunction) {
261
+ // Apply context transform if configured (AgentMessage[] → AgentMessage[])
262
+ let messages = context.messages;
263
+ if (config.transformContext) {
264
+ messages = await config.transformContext(messages, signal);
265
+ }
266
+ // Convert to LLM-compatible messages (AgentMessage[] → Message[])
267
+ const llmMessages = await config.convertToLlm(messages);
268
+ const llmContext = normalizeContext({ messages: llmMessages });
269
+ // Resolve API key (important for expiring tokens)
270
+ const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
271
+ const response = await streamFunction(config.model, llmContext, {
272
+ ...config,
273
+ apiKey: resolvedApiKey,
274
+ signal,
275
+ });
276
+ // Record the requested level, whichever stream function answered.
277
+ const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });
278
+ let partialMessage = null;
279
+ let addedPartial = false;
280
+ for await (const event of response) {
281
+ switch (event.type) {
282
+ case "start":
283
+ partialMessage = event.partial;
284
+ context.messages.push(partialMessage);
285
+ addedPartial = true;
286
+ await emit({ type: "message_start", message: { ...partialMessage } });
287
+ break;
288
+ case "text_start":
289
+ case "text_delta":
290
+ case "text_end":
291
+ case "thinking_start":
292
+ case "thinking_delta":
293
+ case "thinking_end":
294
+ case "toolcall_start":
295
+ case "toolcall_delta":
296
+ case "toolcall_end":
297
+ if (partialMessage) {
298
+ partialMessage = event.partial;
299
+ context.messages[context.messages.length - 1] = partialMessage;
300
+ await emit({
301
+ type: "message_update",
302
+ assistantMessageEvent: event,
303
+ message: { ...partialMessage },
304
+ });
305
+ }
306
+ break;
307
+ case "done":
308
+ case "error": {
309
+ const finalMessage = await result();
310
+ if (addedPartial) {
311
+ context.messages[context.messages.length - 1] = finalMessage;
312
+ }
313
+ else {
314
+ context.messages.push(finalMessage);
315
+ }
316
+ if (!addedPartial) {
317
+ await emit({ type: "message_start", message: { ...finalMessage } });
318
+ }
319
+ await emit({ type: "message_end", message: finalMessage });
320
+ return finalMessage;
321
+ }
322
+ }
323
+ }
324
+ const finalMessage = await result();
325
+ if (addedPartial) {
326
+ context.messages[context.messages.length - 1] = finalMessage;
327
+ }
328
+ else {
329
+ context.messages.push(finalMessage);
330
+ await emit({ type: "message_start", message: { ...finalMessage } });
331
+ }
332
+ await emit({ type: "message_end", message: finalMessage });
333
+ return finalMessage;
334
+ }
335
+ /**
336
+ * Fail all tool calls from an assistant message that was truncated by the
337
+ * output token limit. Streamed tool-call arguments are finalized with a
338
+ * best-effort JSON salvage parser, so a truncated message can yield tool calls
339
+ * whose arguments parse and validate but are silently incomplete. None of them
340
+ * are safe to execute; report each as an error so the model can re-issue them.
341
+ */
342
+ async function failToolCallsFromTruncatedMessage(toolCalls, emit) {
343
+ const messages = [];
344
+ for (const toolCall of toolCalls) {
345
+ await emit({
346
+ type: "tool_execution_start",
347
+ toolCallId: toolCall.id,
348
+ toolName: toolCall.name,
349
+ args: toolCall.arguments,
350
+ });
351
+ const finalized = {
352
+ toolCall,
353
+ result: createErrorToolResult(`Tool call "${toolCall.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`),
354
+ isError: true,
355
+ };
356
+ await emitToolExecutionEnd(finalized, emit);
357
+ const toolResultMessage = createToolResultMessage(finalized);
358
+ await emitToolResultMessage(toolResultMessage, emit);
359
+ messages.push(toolResultMessage);
360
+ }
361
+ return { messages, terminate: false };
362
+ }
363
+ /**
364
+ * Execute tool calls from an assistant message.
365
+ */
366
+ async function executeToolCalls(currentContext, assistantMessage, config, signal, emit) {
367
+ const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
368
+ const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
369
+ if (config.toolExecution === "sequential" || hasSequentialToolCall) {
370
+ return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
371
+ }
372
+ return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);
373
+ }
374
+ async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
375
+ const finalizedCalls = [];
376
+ const messages = [];
377
+ for (const toolCall of toolCalls) {
378
+ await emit({
379
+ type: "tool_execution_start",
380
+ toolCallId: toolCall.id,
381
+ toolName: toolCall.name,
382
+ args: toolCall.arguments,
383
+ });
384
+ const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
385
+ let finalized;
386
+ if (preparation.kind === "immediate") {
387
+ finalized = {
388
+ toolCall,
389
+ result: preparation.result,
390
+ isError: preparation.isError,
391
+ };
392
+ }
393
+ else {
394
+ const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit));
395
+ finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
396
+ }
397
+ await emitToolExecutionEnd(finalized, emit);
398
+ const toolResultMessage = createToolResultMessage(finalized);
399
+ await emitToolResultMessage(toolResultMessage, emit);
400
+ finalizedCalls.push(finalized);
401
+ messages.push(toolResultMessage);
402
+ if (signal?.aborted) {
403
+ break;
404
+ }
405
+ }
406
+ return {
407
+ messages,
408
+ terminate: shouldTerminateToolBatch(finalizedCalls),
409
+ };
410
+ }
411
+ async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit) {
412
+ const finalizedCalls = [];
413
+ for (const toolCall of toolCalls) {
414
+ await emit({
415
+ type: "tool_execution_start",
416
+ toolCallId: toolCall.id,
417
+ toolName: toolCall.name,
418
+ args: toolCall.arguments,
419
+ });
420
+ const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
421
+ if (preparation.kind === "immediate") {
422
+ const finalized = {
423
+ toolCall,
424
+ result: preparation.result,
425
+ isError: preparation.isError,
426
+ };
427
+ await emitToolExecutionEnd(finalized, emit);
428
+ finalizedCalls.push(finalized);
429
+ if (signal?.aborted) {
430
+ break;
431
+ }
432
+ continue;
433
+ }
434
+ finalizedCalls.push(async () => {
435
+ if (signal?.aborted) {
436
+ const finalized = {
437
+ toolCall,
438
+ result: createErrorToolResult("Operation aborted"),
439
+ isError: true,
440
+ };
441
+ await emitToolExecutionEnd(finalized, emit);
442
+ return finalized;
443
+ }
444
+ const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit));
445
+ const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
446
+ await emitToolExecutionEnd(finalized, emit);
447
+ return finalized;
448
+ });
449
+ if (signal?.aborted) {
450
+ break;
451
+ }
452
+ }
453
+ const orderedFinalizedCalls = await Promise.all(finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))));
454
+ const messages = [];
455
+ for (const finalized of orderedFinalizedCalls) {
456
+ const toolResultMessage = createToolResultMessage(finalized);
457
+ await emitToolResultMessage(toolResultMessage, emit);
458
+ messages.push(toolResultMessage);
459
+ }
460
+ return {
461
+ messages,
462
+ terminate: shouldTerminateToolBatch(orderedFinalizedCalls),
463
+ };
464
+ }
465
+ function shouldTerminateToolBatch(finalizedCalls) {
466
+ return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
467
+ }
468
+ function prepareToolCallArguments(tool, toolCall) {
469
+ if (!tool.prepareArguments) {
470
+ return toolCall;
471
+ }
472
+ const preparedArguments = tool.prepareArguments(toolCall.arguments);
473
+ if (preparedArguments === toolCall.arguments) {
474
+ return toolCall;
475
+ }
476
+ return {
477
+ ...toolCall,
478
+ arguments: preparedArguments,
479
+ };
480
+ }
481
+ async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal, tools = currentContext.tools ?? []) {
482
+ const tool = tools.find((t) => t.name === toolCall.name);
483
+ if (!tool) {
484
+ return {
485
+ kind: "immediate",
486
+ result: createErrorToolResult(`Tool ${toolCall.name} not found`),
487
+ isError: true,
488
+ };
489
+ }
490
+ try {
491
+ const preparedToolCall = prepareToolCallArguments(tool, toolCall);
492
+ const validatedArgs = validateToolArguments(tool, preparedToolCall);
493
+ if (config.beforeToolCall) {
494
+ const beforeResult = await config.beforeToolCall({
495
+ assistantMessage,
496
+ toolCall,
497
+ args: validatedArgs,
498
+ context: currentContext,
499
+ }, signal);
500
+ if (signal?.aborted) {
501
+ return {
502
+ kind: "immediate",
503
+ result: createErrorToolResult("Operation aborted"),
504
+ isError: true,
505
+ };
506
+ }
507
+ if (beforeResult?.block) {
508
+ const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
509
+ if (beforeResult.terminate === true) {
510
+ result.terminate = true;
511
+ }
512
+ return {
513
+ kind: "immediate",
514
+ result,
515
+ isError: true,
516
+ };
517
+ }
518
+ }
519
+ if (signal?.aborted) {
520
+ return {
521
+ kind: "immediate",
522
+ result: createErrorToolResult("Operation aborted"),
523
+ isError: true,
524
+ };
525
+ }
526
+ return {
527
+ kind: "prepared",
528
+ toolCall,
529
+ tool,
530
+ args: validatedArgs,
531
+ };
532
+ }
533
+ catch (error) {
534
+ return {
535
+ kind: "immediate",
536
+ result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
537
+ isError: true,
538
+ };
539
+ }
540
+ }
541
+ function emitToolExecutionUpdate(toolCall, emit) {
542
+ return (partialResult) => emit({
543
+ type: "tool_execution_update",
544
+ toolCallId: toolCall.id,
545
+ toolName: toolCall.name,
546
+ args: toolCall.arguments,
547
+ partialResult,
548
+ });
549
+ }
550
+ /**
551
+ * Run one tool call through the same steps as a model-issued call: argument preparation, schema
552
+ * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no
553
+ * messages. Tools that call other tools use this so the hooks (for example permission checks)
554
+ * apply to those calls too.
555
+ *
556
+ * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown
557
+ * errors come back as `isError: true`.
558
+ */
559
+ export async function runToolCall(toolCall, options) {
560
+ const { assistantMessage, context, signal } = options;
561
+ const preparation = await prepareToolCall(context, assistantMessage, toolCall, options, signal, options.tools);
562
+ if (preparation.kind === "immediate") {
563
+ return { toolCall, result: preparation.result, isError: preparation.isError };
564
+ }
565
+ const executed = await executePreparedToolCall(preparation, signal, options.onUpdate ?? (() => { }));
566
+ return finalizeExecutedToolCall(context, assistantMessage, preparation, executed, options, signal);
567
+ }
568
+ async function executePreparedToolCall(prepared, signal, onUpdate) {
569
+ const updateEvents = [];
570
+ let acceptingUpdates = true;
571
+ const startedAt = performance.now();
572
+ const elapsed = () => Math.round(performance.now() - startedAt);
573
+ try {
574
+ const result = await prepared.tool.execute(prepared.toolCall.id, prepared.args, signal, (partialResult) => {
575
+ if (!acceptingUpdates)
576
+ return;
577
+ updateEvents.push(Promise.resolve(onUpdate(partialResult)));
578
+ });
579
+ const durationMs = elapsed();
580
+ acceptingUpdates = false;
581
+ await Promise.all(updateEvents);
582
+ return { result, isError: result.isError === true, durationMs };
583
+ }
584
+ catch (error) {
585
+ const durationMs = elapsed();
586
+ acceptingUpdates = false;
587
+ await Promise.all(updateEvents);
588
+ return {
589
+ result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
590
+ isError: true,
591
+ durationMs,
592
+ };
593
+ }
594
+ finally {
595
+ acceptingUpdates = false;
596
+ }
597
+ }
598
+ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, signal) {
599
+ let result = executed.result;
600
+ let isError = executed.isError;
601
+ if (config.afterToolCall) {
602
+ try {
603
+ const afterResult = await config.afterToolCall({
604
+ assistantMessage,
605
+ toolCall: prepared.toolCall,
606
+ args: prepared.args,
607
+ result,
608
+ isError,
609
+ context: currentContext,
610
+ }, signal);
611
+ if (afterResult) {
612
+ // Structured content not replaced along with the content may no longer match it.
613
+ const structuredContent = afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent);
614
+ result = {
615
+ ...result,
616
+ content: afterResult.content ?? result.content,
617
+ details: afterResult.details ?? result.details,
618
+ usage: afterResult.usage ?? result.usage,
619
+ terminate: afterResult.terminate ?? result.terminate,
620
+ };
621
+ if (structuredContent === undefined)
622
+ delete result.structuredContent;
623
+ else
624
+ result.structuredContent = structuredContent;
625
+ isError = afterResult.isError ?? isError;
626
+ }
627
+ }
628
+ catch (error) {
629
+ result = createErrorToolResult(error instanceof Error ? error.message : String(error));
630
+ isError = true;
631
+ }
632
+ }
633
+ return {
634
+ toolCall: prepared.toolCall,
635
+ result,
636
+ isError,
637
+ durationMs: executed.durationMs,
638
+ };
639
+ }
640
+ function createErrorToolResult(message) {
641
+ return {
642
+ content: [{ type: "text", text: message }],
643
+ details: {},
644
+ };
645
+ }
646
+ async function emitToolExecutionEnd(finalized, emit) {
647
+ await emit({
648
+ type: "tool_execution_end",
649
+ toolCallId: finalized.toolCall.id,
650
+ toolName: finalized.toolCall.name,
651
+ result: finalized.result,
652
+ isError: finalized.isError,
653
+ ...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
654
+ });
655
+ }
656
+ function createToolResultMessage(finalized) {
657
+ return {
658
+ role: "toolResult",
659
+ toolCallId: finalized.toolCall.id,
660
+ toolName: finalized.toolCall.name,
661
+ // Untyped tools (JS extensions) can return results without content; normalize
662
+ // so the null never enters session history or provider payloads.
663
+ content: finalized.result.content ?? [],
664
+ details: finalized.result.details,
665
+ usage: finalized.result.usage,
666
+ isError: finalized.isError,
667
+ ...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
668
+ timestamp: Date.now(),
669
+ };
670
+ }
671
+ async function emitToolResultMessage(toolResultMessage, emit) {
672
+ await emit({ type: "message_start", message: toolResultMessage });
673
+ await emit({ type: "message_end", message: toolResultMessage });
674
+ }
675
+ //# sourceMappingURL=agent-loop.js.map