@bubblebrain-ai/bubble 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/agent/execution-governor.js +1 -1
  2. package/dist/agent/input-controller.d.ts +11 -0
  3. package/dist/agent/input-controller.js +30 -0
  4. package/dist/agent/tool-intent.js +1 -0
  5. package/dist/agent.d.ts +8 -4
  6. package/dist/agent.js +623 -312
  7. package/dist/approval/controller.d.ts +1 -0
  8. package/dist/approval/controller.js +20 -3
  9. package/dist/approval/tool-helper.js +2 -0
  10. package/dist/approval/types.d.ts +14 -1
  11. package/dist/context/compact.js +9 -3
  12. package/dist/context/projector.js +27 -12
  13. package/dist/debug-trace.d.ts +27 -0
  14. package/dist/debug-trace.js +385 -0
  15. package/dist/feishu/agent-host/approval-card.js +9 -0
  16. package/dist/feishu/serve.js +7 -1
  17. package/dist/main.js +86 -9
  18. package/dist/model-catalog.js +1 -0
  19. package/dist/orchestrator/default-hooks.js +19 -8
  20. package/dist/orchestrator/hooks.d.ts +1 -0
  21. package/dist/prompt/environment.js +2 -0
  22. package/dist/prompt/reminders.d.ts +5 -6
  23. package/dist/prompt/reminders.js +8 -9
  24. package/dist/prompt/runtime.js +2 -2
  25. package/dist/provider-openai-codex.d.ts +7 -0
  26. package/dist/provider-openai-codex.js +265 -124
  27. package/dist/provider-registry.d.ts +2 -0
  28. package/dist/provider-registry.js +58 -9
  29. package/dist/provider.d.ts +3 -0
  30. package/dist/provider.js +5 -1
  31. package/dist/session-log.js +13 -1
  32. package/dist/slash-commands/commands.js +39 -0
  33. package/dist/slash-commands/types.d.ts +12 -0
  34. package/dist/stats/usage.d.ts +52 -0
  35. package/dist/stats/usage.js +414 -0
  36. package/dist/tools/apply-patch.d.ts +9 -0
  37. package/dist/tools/apply-patch.js +330 -0
  38. package/dist/tools/bash.js +205 -44
  39. package/dist/tools/edit-apply.d.ts +5 -2
  40. package/dist/tools/edit-apply.js +221 -31
  41. package/dist/tools/edit.js +12 -3
  42. package/dist/tools/file-mutation-queue.d.ts +1 -0
  43. package/dist/tools/file-mutation-queue.js +12 -1
  44. package/dist/tools/index.d.ts +2 -0
  45. package/dist/tools/index.js +7 -1
  46. package/dist/tools/patch-apply.d.ts +41 -0
  47. package/dist/tools/patch-apply.js +312 -0
  48. package/dist/tools/server-manager.d.ts +36 -0
  49. package/dist/tools/server-manager.js +234 -0
  50. package/dist/tools/server.d.ts +6 -0
  51. package/dist/tools/server.js +245 -0
  52. package/dist/tools/write.d.ts +3 -6
  53. package/dist/tools/write.js +26 -46
  54. package/dist/tui/clipboard.d.ts +1 -0
  55. package/dist/tui/clipboard.js +53 -0
  56. package/dist/tui/detect-theme.d.ts +2 -0
  57. package/dist/tui/detect-theme.js +87 -0
  58. package/dist/tui/display-history.d.ts +63 -0
  59. package/dist/tui/display-history.js +306 -0
  60. package/dist/tui/edit-diff.d.ts +11 -0
  61. package/dist/tui/edit-diff.js +57 -0
  62. package/dist/tui/escape-confirmation.d.ts +15 -0
  63. package/dist/tui/escape-confirmation.js +30 -0
  64. package/dist/tui/file-mentions.d.ts +29 -0
  65. package/dist/tui/file-mentions.js +174 -0
  66. package/dist/tui/global-key-router.d.ts +3 -0
  67. package/dist/tui/global-key-router.js +87 -0
  68. package/dist/tui/image-paste.d.ts +95 -0
  69. package/dist/tui/image-paste.js +505 -0
  70. package/dist/tui/input-history.d.ts +16 -0
  71. package/dist/tui/input-history.js +79 -0
  72. package/dist/tui/markdown-inline.d.ts +22 -0
  73. package/dist/tui/markdown-inline.js +68 -0
  74. package/dist/tui/markdown-theme-rules.d.ts +23 -0
  75. package/dist/tui/markdown-theme-rules.js +164 -0
  76. package/dist/tui/markdown-theme.d.ts +5 -0
  77. package/dist/tui/markdown-theme.js +27 -0
  78. package/dist/tui/model-picker-data.d.ts +10 -0
  79. package/dist/tui/model-picker-data.js +32 -0
  80. package/dist/tui/opencode-spinner.d.ts +22 -0
  81. package/dist/tui/opencode-spinner.js +216 -0
  82. package/dist/tui/prompt-keybindings.d.ts +42 -0
  83. package/dist/tui/prompt-keybindings.js +35 -0
  84. package/dist/tui/recent-activity.d.ts +8 -0
  85. package/dist/tui/recent-activity.js +71 -0
  86. package/dist/tui/render-signature.d.ts +1 -0
  87. package/dist/tui/render-signature.js +7 -0
  88. package/dist/tui/run.d.ts +45 -0
  89. package/dist/tui/run.js +9359 -0
  90. package/dist/tui/session-display.d.ts +6 -0
  91. package/dist/tui/session-display.js +12 -0
  92. package/dist/tui/sidebar-mcp.d.ts +31 -0
  93. package/dist/tui/sidebar-mcp.js +62 -0
  94. package/dist/tui/sidebar-state.d.ts +12 -0
  95. package/dist/tui/sidebar-state.js +69 -0
  96. package/dist/tui/streaming-tool-args.d.ts +15 -0
  97. package/dist/tui/streaming-tool-args.js +30 -0
  98. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  99. package/dist/tui/tool-renderers/fallback.js +75 -0
  100. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  101. package/dist/tui/tool-renderers/registry.js +11 -0
  102. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  103. package/dist/tui/tool-renderers/subagent.js +135 -0
  104. package/dist/tui/tool-renderers/types.d.ts +36 -0
  105. package/dist/tui/tool-renderers/types.js +1 -0
  106. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  107. package/dist/tui/tool-renderers/write-preview.js +32 -0
  108. package/dist/tui/tool-renderers/write.d.ts +6 -0
  109. package/dist/tui/tool-renderers/write.js +88 -0
  110. package/dist/tui/trace-groups.d.ts +27 -0
  111. package/dist/tui/trace-groups.js +419 -0
  112. package/dist/tui/wordmark.d.ts +15 -0
  113. package/dist/tui/wordmark.js +179 -0
  114. package/dist/tui-ink/app.js +45 -9
  115. package/dist/tui-ink/approval/approval-dialog.js +7 -1
  116. package/dist/tui-ink/display-history.d.ts +1 -0
  117. package/dist/tui-ink/display-history.js +5 -4
  118. package/dist/tui-ink/message-list.js +23 -9
  119. package/dist/tui-ink/theme.d.ts +3 -9
  120. package/dist/tui-ink/theme.js +39 -45
  121. package/dist/tui-ink/trace-groups.js +1 -1
  122. package/dist/tui-ink/welcome.js +22 -78
  123. package/dist/tui-opentui/app.d.ts +54 -0
  124. package/dist/tui-opentui/app.js +1365 -0
  125. package/dist/tui-opentui/approval/approval-dialog.d.ts +15 -0
  126. package/dist/tui-opentui/approval/approval-dialog.js +145 -0
  127. package/dist/tui-opentui/approval/diff-view.d.ts +9 -0
  128. package/dist/tui-opentui/approval/diff-view.js +43 -0
  129. package/dist/tui-opentui/approval/select.d.ts +37 -0
  130. package/dist/tui-opentui/approval/select.js +91 -0
  131. package/dist/tui-opentui/detect-theme.d.ts +2 -0
  132. package/dist/tui-opentui/detect-theme.js +87 -0
  133. package/dist/tui-opentui/display-history.d.ts +56 -0
  134. package/dist/tui-opentui/display-history.js +130 -0
  135. package/dist/tui-opentui/edit-diff.d.ts +11 -0
  136. package/dist/tui-opentui/edit-diff.js +57 -0
  137. package/dist/tui-opentui/feedback-dialog.d.ts +21 -0
  138. package/dist/tui-opentui/feedback-dialog.js +164 -0
  139. package/dist/tui-opentui/feishu-setup-picker.d.ts +7 -0
  140. package/dist/tui-opentui/feishu-setup-picker.js +272 -0
  141. package/dist/tui-opentui/file-mentions.d.ts +29 -0
  142. package/dist/tui-opentui/file-mentions.js +174 -0
  143. package/dist/tui-opentui/footer.d.ts +26 -0
  144. package/dist/tui-opentui/footer.js +40 -0
  145. package/dist/tui-opentui/image-paste.d.ts +54 -0
  146. package/dist/tui-opentui/image-paste.js +288 -0
  147. package/dist/tui-opentui/input-box.d.ts +34 -0
  148. package/dist/tui-opentui/input-box.js +471 -0
  149. package/dist/tui-opentui/input-history.d.ts +16 -0
  150. package/dist/tui-opentui/input-history.js +79 -0
  151. package/dist/tui-opentui/markdown.d.ts +66 -0
  152. package/dist/tui-opentui/markdown.js +127 -0
  153. package/dist/tui-opentui/message-list.d.ts +31 -0
  154. package/dist/tui-opentui/message-list.js +128 -0
  155. package/dist/tui-opentui/model-picker.d.ts +63 -0
  156. package/dist/tui-opentui/model-picker.js +450 -0
  157. package/dist/tui-opentui/plan-confirm.d.ts +9 -0
  158. package/dist/tui-opentui/plan-confirm.js +124 -0
  159. package/dist/tui-opentui/question-dialog.d.ts +10 -0
  160. package/dist/tui-opentui/question-dialog.js +110 -0
  161. package/dist/tui-opentui/recent-activity.d.ts +8 -0
  162. package/dist/tui-opentui/recent-activity.js +71 -0
  163. package/dist/tui-opentui/run-session-picker.d.ts +10 -0
  164. package/dist/tui-opentui/run-session-picker.js +28 -0
  165. package/dist/tui-opentui/run.d.ts +38 -0
  166. package/dist/tui-opentui/run.js +48 -0
  167. package/dist/tui-opentui/session-picker.d.ts +12 -0
  168. package/dist/tui-opentui/session-picker.js +120 -0
  169. package/dist/tui-opentui/theme.d.ts +89 -0
  170. package/dist/tui-opentui/theme.js +157 -0
  171. package/dist/tui-opentui/todos.d.ts +9 -0
  172. package/dist/tui-opentui/todos.js +45 -0
  173. package/dist/tui-opentui/trace-groups.d.ts +27 -0
  174. package/dist/tui-opentui/trace-groups.js +419 -0
  175. package/dist/tui-opentui/use-terminal-size.d.ts +4 -0
  176. package/dist/tui-opentui/use-terminal-size.js +5 -0
  177. package/dist/tui-opentui/welcome.d.ts +25 -0
  178. package/dist/tui-opentui/welcome.js +77 -0
  179. package/dist/types.d.ts +36 -2
  180. package/package.json +5 -1
package/dist/agent.js CHANGED
@@ -22,6 +22,8 @@ import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subage
22
22
  import { buildSystemPrompt } from "./system-prompt.js";
23
23
  import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
24
24
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
25
+ import { stopAutoServersForSession } from "./tools/server-manager.js";
26
+ import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceMessage, summarizeTraceToolResult, summarizeTraceValue, traceEvent, } from "./debug-trace.js";
25
27
  const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
26
28
  const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
27
29
  const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
@@ -29,6 +31,12 @@ const RESIDENT_HISTORY_CHAR_SOFT_LIMIT = 256 * 1024;
29
31
  const RESIDENT_HISTORY_CHAR_HARD_LIMIT = 512 * 1024;
30
32
  const RESIDENT_HISTORY_HEAP_SOFT_LIMIT = 512 * 1024 * 1024;
31
33
  const RESIDENT_HISTORY_HEAP_HARD_LIMIT = 768 * 1024 * 1024;
34
+ const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
35
+ const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained no user-visible assistant content and no tool calls. " +
36
+ "Respond now with a concise, user-visible answer, or call an available tool if more work is required. " +
37
+ "Do not put the final answer only in hidden reasoning.";
38
+ const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
39
+ const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
32
40
  export class AgentAbortError extends Error {
33
41
  constructor(message = "Agent run cancelled.") {
34
42
  super(message);
@@ -236,6 +244,24 @@ export class Agent {
236
244
  }
237
245
  async *run(userInput, cwd, options = {}) {
238
246
  const abortSignal = composeAbortSignals([options.abortSignal, this.budgetLedger?.signal]);
247
+ const inputController = options.inputController;
248
+ const traceContext = {
249
+ cwd,
250
+ sessionFile: this.sessionID,
251
+ provider: this._providerId || "none",
252
+ model: this.apiModel || "none",
253
+ };
254
+ const emit = (event) => {
255
+ traceEvent("agent_event", summarizeAgentEventForTrace(event), traceContext);
256
+ return event;
257
+ };
258
+ traceEvent("agent_run_start", {
259
+ input: summarizeTraceValue(userInput),
260
+ mode: this._mode,
261
+ messageCount: this.messages.length,
262
+ toolCount: this.tools.size,
263
+ deferredUnlocked: this.unlockedDeferred.size,
264
+ }, traceContext);
239
265
  throwIfAborted(abortSignal);
240
266
  const hookBus = new HookBus();
241
267
  for (const hooks of createDefaultHooks()) {
@@ -249,6 +275,39 @@ export class Agent {
249
275
  const queueReminder = (reminder) => {
250
276
  reminderQueue.push(reminder);
251
277
  };
278
+ const pendingInputCount = () => inputController?.pendingInputCount() ?? 0;
279
+ const applyPendingInputs = () => {
280
+ const pendingInputs = inputController?.drainPendingInputs() ?? [];
281
+ if (pendingInputs.length === 0)
282
+ return [];
283
+ for (const input of pendingInputs) {
284
+ this.appendMessage({ role: "user", content: input.content });
285
+ }
286
+ return [
287
+ ...pendingInputs.map((input) => ({
288
+ type: "input_applied",
289
+ id: input.id,
290
+ content: input.content,
291
+ target: "current_turn",
292
+ })),
293
+ { type: "input_pending_changed", pending: pendingInputCount() },
294
+ ];
295
+ };
296
+ const rejectPendingInputs = (reason) => {
297
+ const pendingInputs = inputController?.drainPendingInputs() ?? [];
298
+ if (pendingInputs.length === 0)
299
+ return [];
300
+ return [
301
+ ...pendingInputs.map((input) => ({
302
+ type: "input_rejected",
303
+ id: input.id,
304
+ content: input.content,
305
+ reason,
306
+ target: "next_turn",
307
+ })),
308
+ { type: "input_pending_changed", pending: pendingInputCount() },
309
+ ];
310
+ };
252
311
  const flushGovernorReminders = () => {
253
312
  for (const reminder of reminderQueue.splice(0, reminderQueue.length)) {
254
313
  this.injectSystemReminder(reminder);
@@ -256,7 +315,7 @@ export class Agent {
256
315
  };
257
316
  if (this._todos.length > 0 && this._todos.every((t) => t.status === "completed")) {
258
317
  this.setTodos([]);
259
- yield { type: "todos_updated", todos: [] };
318
+ yield emit({ type: "todos_updated", todos: [] });
260
319
  }
261
320
  this.appendMessage({ role: "user", content: userInput });
262
321
  await hookBus.runBeforeTurn({
@@ -269,346 +328,487 @@ export class Agent {
269
328
  });
270
329
  flushGovernorReminders();
271
330
  let consecutiveOverflowRecoveries = 0;
331
+ let consecutiveEmptyAssistantRecoveries = 0;
272
332
  let step = 0;
273
- while (true) {
274
- throwIfAborted(abortSignal);
275
- flushGovernorReminders();
276
- for (const update of this.drainSubagentToolUpdates())
277
- yield update;
278
- yield { type: "turn_start" };
279
- step += 1;
280
- hookState.turnCount = step;
281
- if (this.taskBudget) {
282
- hookState.taskBudget = {
283
- total: this.taskBudget.total,
284
- spent: hookState.taskBudget?.spent ?? 0,
285
- };
286
- }
287
- let forceTextOnlyReason = hookState.forceTextOnlyReason;
288
- if (!forceTextOnlyReason && this.maxTurns !== undefined && step >= this.maxTurns) {
289
- forceTextOnlyReason = "The configured maximum turns for this agent have been reached.";
290
- hookState.forceTextOnlyReason = forceTextOnlyReason;
291
- }
292
- if (forceTextOnlyReason) {
293
- this.injectSystemReminder(buildToolFreezeReminder(forceTextOnlyReason));
294
- }
295
- const assistantMsg = {
296
- role: "assistant",
297
- content: "",
298
- reasoning: "",
299
- toolCalls: [],
300
- };
301
- const streamingToolCalls = new Map();
302
- let turnUsage;
303
- let assistantAppended = false;
304
- let toolEntries = Array.from(this.tools.values())
305
- .filter((t) => !t.deferred || this.unlockedDeferred.has(t.name));
306
- const beforeModelCallCtx = {
307
- agent: this,
308
- cwd,
309
- input: userInput,
310
- state: hookState,
311
- queueReminder,
312
- flushReminders: flushGovernorReminders,
313
- toolEntries,
314
- disableTools: (reason) => {
315
- hookState.forceTextOnlyReason = reason;
316
- },
317
- };
318
- await hookBus.runBeforeModelCall(beforeModelCallCtx);
319
- toolEntries = beforeModelCallCtx.toolEntries;
320
- if (this._mode !== "plan") {
321
- toolEntries = toolEntries.filter((t) => t.name !== "exit_plan_mode");
322
- }
323
- flushGovernorReminders();
324
- const toolDefinitions = ((hookState.forceTextOnlyReason ? [] : toolEntries))
325
- .map((t) => ({
326
- name: t.name,
327
- description: t.description,
328
- parameters: t.parameters,
329
- }));
330
- // LLM-driven compaction runs ahead of projector's algorithmic passes. If
331
- // it succeeds, this.messages is replaced with [preserved system+meta] +
332
- // [LLM summary] + [last user msg], and the projector becomes a no-op for
333
- // budget. If it fails (network error, etc.), the projector's existing
334
- // algorithmic fallback still kicks in.
335
- await this.maybeCompactWithLLM();
336
- try {
337
- const projectedMessages = projectMessages(this.messages, {
338
- mode: "budgeted",
333
+ let autoServersStopped = false;
334
+ const stopOwnedAutoServers = async () => {
335
+ if (autoServersStopped)
336
+ return;
337
+ autoServersStopped = true;
338
+ await stopAutoServersForSession(this.sessionID);
339
+ };
340
+ let currentAssistantMsg;
341
+ let currentAssistantAppended = false;
342
+ try {
343
+ while (true) {
344
+ throwIfAborted(abortSignal);
345
+ flushGovernorReminders();
346
+ for (const update of this.drainSubagentToolUpdates())
347
+ yield emit(update);
348
+ for (const event of applyPendingInputs())
349
+ yield emit(event);
350
+ yield emit({ type: "turn_start" });
351
+ step += 1;
352
+ hookState.turnCount = step;
353
+ if (this.taskBudget) {
354
+ hookState.taskBudget = {
355
+ total: this.taskBudget.total,
356
+ spent: hookState.taskBudget?.spent ?? 0,
357
+ };
358
+ }
359
+ let forceTextOnlyReason = hookState.forceTextOnlyReason;
360
+ if (!forceTextOnlyReason && this.maxTurns !== undefined && step >= this.maxTurns) {
361
+ forceTextOnlyReason = "The configured maximum turns for this agent have been reached.";
362
+ hookState.forceTextOnlyReason = forceTextOnlyReason;
363
+ }
364
+ if (forceTextOnlyReason) {
365
+ this.injectSystemReminder(buildToolFreezeReminder(forceTextOnlyReason));
366
+ }
367
+ const assistantMsg = {
368
+ role: "assistant",
369
+ content: "",
370
+ reasoning: "",
371
+ toolCalls: [],
372
+ model: this._model,
339
373
  providerId: this.providerId,
340
374
  modelId: this.apiModel,
341
- usageAnchorTokens: this.lastInputTokens ?? undefined,
342
- anchorMessageCount: this.lastAnchorMessageCount ?? undefined,
343
- });
344
- const stream = this.provider.streamChat(projectedMessages, {
345
- model: this.apiModel,
346
- tools: toolDefinitions,
347
- temperature: this.temperature,
348
- thinkingLevel: this.thinkingLevel,
349
- abortSignal,
350
- });
351
- for await (const chunk of stream) {
352
- throwIfAborted(abortSignal);
353
- switch (chunk.type) {
354
- case "text":
355
- assistantMsg.content += chunk.content;
356
- yield { type: "text_delta", content: chunk.content };
357
- break;
358
- case "reasoning_delta":
359
- debugReasoningStream({
360
- stage: "agent_receive",
361
- providerId: this._providerId,
362
- modelId: this.apiModel,
363
- turnStep: step,
364
- beforeLength: assistantMsg.reasoning?.length ?? 0,
365
- delta: summarizeDebugText(chunk.content),
366
- afterLength: (assistantMsg.reasoning?.length ?? 0) + chunk.content.length,
367
- });
368
- assistantMsg.reasoning = (assistantMsg.reasoning || "") + chunk.content;
369
- yield { type: "reasoning_delta", content: chunk.content };
370
- break;
371
- case "tool_call":
372
- if (chunk.isStart) {
373
- streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
374
- yield { type: "tool_call_start", id: chunk.id, name: chunk.name };
375
- }
376
- if (!streamingToolCalls.has(chunk.id)) {
377
- streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
378
- }
379
- const currentToolCall = streamingToolCalls.get(chunk.id);
380
- if (currentToolCall) {
381
- currentToolCall.name = chunk.name || currentToolCall.name;
382
- currentToolCall.args += chunk.arguments;
383
- if (chunk.argumentsFull !== undefined) {
384
- currentToolCall.args = chunk.argumentsFull;
375
+ };
376
+ const streamingToolCalls = new Map();
377
+ let turnUsage;
378
+ let assistantAppended = false;
379
+ currentAssistantMsg = assistantMsg;
380
+ currentAssistantAppended = false;
381
+ let toolEntries = Array.from(this.tools.values())
382
+ .filter((t) => !t.deferred || this.unlockedDeferred.has(t.name));
383
+ const beforeModelCallCtx = {
384
+ agent: this,
385
+ cwd,
386
+ input: userInput,
387
+ state: hookState,
388
+ queueReminder,
389
+ flushReminders: flushGovernorReminders,
390
+ toolEntries,
391
+ disableTools: (reason) => {
392
+ hookState.forceTextOnlyReason = reason;
393
+ },
394
+ };
395
+ await hookBus.runBeforeModelCall(beforeModelCallCtx);
396
+ toolEntries = beforeModelCallCtx.toolEntries;
397
+ if (this._mode !== "plan") {
398
+ toolEntries = toolEntries.filter((t) => t.name !== "exit_plan_mode");
399
+ }
400
+ flushGovernorReminders();
401
+ const toolDefinitions = ((hookState.forceTextOnlyReason ? [] : toolEntries))
402
+ .map((t) => ({
403
+ name: t.name,
404
+ description: t.description,
405
+ parameters: t.parameters,
406
+ }));
407
+ // LLM-driven compaction runs ahead of projector's algorithmic passes. If
408
+ // it succeeds, this.messages is replaced with [preserved system+meta] +
409
+ // [LLM summary] + [last user msg], and the projector becomes a no-op for
410
+ // budget. If it fails (network error, etc.), the projector's existing
411
+ // algorithmic fallback still kicks in.
412
+ await this.maybeCompactWithLLM();
413
+ try {
414
+ const projectedMessages = projectMessages(this.messages, {
415
+ mode: "budgeted",
416
+ providerId: this.providerId,
417
+ modelId: this.apiModel,
418
+ usageAnchorTokens: this.lastInputTokens ?? undefined,
419
+ anchorMessageCount: this.lastAnchorMessageCount ?? undefined,
420
+ });
421
+ const providerStartedAt = Date.now();
422
+ let streamTextChars = 0;
423
+ let streamReasoningChars = 0;
424
+ let streamToolCallDeltas = 0;
425
+ traceEvent("provider_stream_start", {
426
+ residentMessageCount: this.messages.length,
427
+ projectedMessageCount: projectedMessages.length,
428
+ toolCount: toolDefinitions.length,
429
+ thinkingLevel: this.thinkingLevel,
430
+ mode: this._mode,
431
+ }, traceContext);
432
+ const stream = this.provider.streamChat(projectedMessages, {
433
+ model: this.apiModel,
434
+ tools: toolDefinitions,
435
+ temperature: this.temperature,
436
+ thinkingLevel: this.thinkingLevel,
437
+ abortSignal,
438
+ });
439
+ for await (const chunk of stream) {
440
+ throwIfAborted(abortSignal);
441
+ switch (chunk.type) {
442
+ case "text":
443
+ assistantMsg.content += chunk.content;
444
+ streamTextChars += chunk.content.length;
445
+ yield emit({ type: "text_delta", content: chunk.content });
446
+ break;
447
+ case "reasoning_delta":
448
+ debugReasoningStream({
449
+ stage: "agent_receive",
450
+ providerId: this._providerId,
451
+ modelId: this.apiModel,
452
+ turnStep: step,
453
+ beforeLength: assistantMsg.reasoning?.length ?? 0,
454
+ delta: summarizeDebugText(chunk.content),
455
+ afterLength: (assistantMsg.reasoning?.length ?? 0) + chunk.content.length,
456
+ });
457
+ assistantMsg.reasoning = (assistantMsg.reasoning || "") + chunk.content;
458
+ streamReasoningChars += chunk.content.length;
459
+ yield emit({ type: "reasoning_delta", content: chunk.content });
460
+ break;
461
+ case "tool_call":
462
+ if (chunk.isStart) {
463
+ streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
464
+ yield emit({ type: "tool_call_start", id: chunk.id, name: chunk.name });
385
465
  }
386
- if (chunk.argumentsCorrupt) {
387
- currentToolCall.argsCorrupt = true;
466
+ if (!streamingToolCalls.has(chunk.id)) {
467
+ streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
388
468
  }
389
- if (chunk.arguments) {
390
- yield {
391
- type: "tool_call_delta",
469
+ const currentToolCall = streamingToolCalls.get(chunk.id);
470
+ if (currentToolCall) {
471
+ currentToolCall.name = chunk.name || currentToolCall.name;
472
+ currentToolCall.args += chunk.arguments;
473
+ if (chunk.argumentsFull !== undefined) {
474
+ currentToolCall.args = chunk.argumentsFull;
475
+ }
476
+ if (chunk.argumentsCorrupt) {
477
+ currentToolCall.argsCorrupt = true;
478
+ }
479
+ if (chunk.arguments) {
480
+ streamToolCallDeltas += 1;
481
+ yield emit({
482
+ type: "tool_call_delta",
483
+ id: currentToolCall.id,
484
+ name: currentToolCall.name,
485
+ argumentsDelta: chunk.arguments,
486
+ arguments: currentToolCall.args,
487
+ });
488
+ }
489
+ }
490
+ if (chunk.isEnd && currentToolCall) {
491
+ assistantMsg.toolCalls.push({
392
492
  id: currentToolCall.id,
393
493
  name: currentToolCall.name,
394
- argumentsDelta: chunk.arguments,
395
494
  arguments: currentToolCall.args,
396
- };
495
+ ...(currentToolCall.argsCorrupt ? { argsCorrupt: true } : {}),
496
+ });
497
+ yield emit({
498
+ type: "tool_call_end",
499
+ id: currentToolCall.id,
500
+ name: currentToolCall.name,
501
+ arguments: currentToolCall.args,
502
+ });
503
+ streamingToolCalls.delete(chunk.id);
397
504
  }
398
- }
399
- if (chunk.isEnd && currentToolCall) {
400
- assistantMsg.toolCalls.push({
401
- id: currentToolCall.id,
402
- name: currentToolCall.name,
403
- arguments: currentToolCall.args,
404
- ...(currentToolCall.argsCorrupt ? { argsCorrupt: true } : {}),
405
- });
406
- yield {
407
- type: "tool_call_end",
408
- id: currentToolCall.id,
409
- name: currentToolCall.name,
410
- arguments: currentToolCall.args,
411
- };
412
- streamingToolCalls.delete(chunk.id);
413
- }
414
- break;
415
- case "usage":
416
- turnUsage = chunk.usage;
417
- this.budgetLedger?.recordUsage(chunk.usage, this.budgetSource);
418
- this.lastInputTokens = chunk.usage.promptTokens;
419
- this.lastAnchorMessageCount = this.messages.length;
420
- if (hookState.taskBudget) {
421
- hookState.taskBudget.spent += chunk.usage.promptTokens + chunk.usage.completionTokens;
422
- if (hookState.taskBudget.spent >= hookState.taskBudget.total) {
423
- hookState.forceTextOnlyReason = "The configured task budget for this agent has been exhausted.";
505
+ break;
506
+ case "usage":
507
+ turnUsage = chunk.usage;
508
+ assistantMsg.usage = chunk.usage;
509
+ this.budgetLedger?.recordUsage(chunk.usage, this.budgetSource);
510
+ this.lastInputTokens = chunk.usage.promptTokens;
511
+ this.lastAnchorMessageCount = this.messages.length;
512
+ if (hookState.taskBudget) {
513
+ hookState.taskBudget.spent += chunk.usage.promptTokens + chunk.usage.completionTokens;
514
+ if (hookState.taskBudget.spent >= hookState.taskBudget.total) {
515
+ hookState.forceTextOnlyReason = "The configured task budget for this agent has been exhausted.";
516
+ }
424
517
  }
425
- }
426
- break;
518
+ break;
519
+ }
520
+ for (const update of this.drainSubagentToolUpdates())
521
+ yield emit(update);
427
522
  }
428
- for (const update of this.drainSubagentToolUpdates())
429
- yield update;
430
- }
431
- throwIfAborted(abortSignal);
432
- this.appendMessage(assistantMsg);
433
- assistantAppended = true;
434
- }
435
- catch (error) {
436
- if (assistantAppended) {
437
- throw error;
438
- }
439
- if (!isContextOverflowError(error)) {
440
- throw error;
441
- }
442
- if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
443
- throw error;
523
+ traceEvent("provider_stream_end", {
524
+ elapsedMs: Date.now() - providerStartedAt,
525
+ textChars: streamTextChars,
526
+ reasoningChars: streamReasoningChars,
527
+ toolCallDeltas: streamToolCallDeltas,
528
+ toolCalls: assistantMsg.toolCalls?.length ?? 0,
529
+ usage: turnUsage,
530
+ }, traceContext);
531
+ throwIfAborted(abortSignal);
532
+ const assistantHasContent = assistantMsg.content.trim().length > 0;
533
+ const assistantHasToolCalls = !!assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0;
534
+ if (!assistantHasContent && !assistantHasToolCalls) {
535
+ if (consecutiveEmptyAssistantRecoveries < MAX_EMPTY_ASSISTANT_RECOVERIES) {
536
+ consecutiveEmptyAssistantRecoveries += 1;
537
+ this.injectSystemReminder(EMPTY_ASSISTANT_RECOVERY_REMINDER);
538
+ yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
539
+ continue;
540
+ }
541
+ assistantMsg.content = EMPTY_ASSISTANT_FALLBACK;
542
+ assistantMsg.reasoning = "";
543
+ yield emit({ type: "text_delta", content: assistantMsg.content });
544
+ }
545
+ this.appendMessage(assistantMsg);
546
+ assistantAppended = true;
547
+ currentAssistantAppended = true;
444
548
  }
445
- const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
446
- consecutiveOverflowRecoveries += 1;
447
- yield { type: "context_recovered", droppedMessages, reason: "overflow" };
448
- continue;
449
- }
450
- consecutiveOverflowRecoveries = 0;
451
- // Execute tools if any
452
- if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
453
- const parsedCalls = [];
454
- for (let index = 0; index < assistantMsg.toolCalls.length; index++) {
455
- const tc = assistantMsg.toolCalls[index];
456
- try {
457
- parsedCalls.push({
458
- ...tc,
459
- parsedArgs: JSON.parse(tc.arguments),
460
- ...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
461
- });
549
+ catch (error) {
550
+ traceEvent("provider_stream_error", {
551
+ error: summarizeTraceError(error),
552
+ }, traceContext);
553
+ if (assistantAppended) {
554
+ throw error;
462
555
  }
463
- catch {
464
- parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
556
+ if (!isContextOverflowError(error)) {
557
+ if (!isAbortLikeError(error, abortSignal) && shouldAppendModelInterruptedBoundary(this.messages)) {
558
+ this.appendMessage(createModelInterruptedMessage(error, {
559
+ model: this._model,
560
+ providerId: this.providerId,
561
+ modelId: this.apiModel,
562
+ }));
563
+ assistantAppended = true;
564
+ }
565
+ throw error;
566
+ }
567
+ if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
568
+ throw error;
465
569
  }
570
+ const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
571
+ consecutiveOverflowRecoveries += 1;
572
+ yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
573
+ continue;
466
574
  }
467
- const executedResults = [];
468
- for (let index = 0; index < parsedCalls.length; index++) {
469
- throwIfAborted(abortSignal);
470
- let tc = parsedCalls[index];
471
- let blockedResult;
472
- await hookBus.runBeforeToolCall({
473
- agent: this,
474
- cwd,
475
- input: userInput,
476
- state: hookState,
477
- queueReminder,
478
- flushReminders: flushGovernorReminders,
479
- toolCall: tc,
480
- blockedResult,
481
- replaceToolCall: (toolCall) => {
482
- tc = toolCall;
483
- },
484
- blockToolCall: (result) => {
485
- blockedResult = result;
486
- },
487
- });
488
- assistantMsg.toolCalls[index] = {
489
- id: tc.id,
490
- name: tc.name,
491
- arguments: tc.arguments,
492
- };
493
- flushGovernorReminders();
494
- yield { type: "tool_start", id: tc.id, name: tc.name, args: tc.parsedArgs };
495
- const todosVersionBefore = this._todosVersion;
496
- const modeVersionBefore = this._modeVersion;
497
- const updateQueue = createUpdateQueue();
498
- let result;
499
- if (blockedResult) {
500
- result = blockedResult;
575
+ consecutiveOverflowRecoveries = 0;
576
+ consecutiveEmptyAssistantRecoveries = 0;
577
+ // Execute tools if any
578
+ if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
579
+ const parsedCalls = [];
580
+ for (let index = 0; index < assistantMsg.toolCalls.length; index++) {
581
+ const tc = assistantMsg.toolCalls[index];
582
+ try {
583
+ parsedCalls.push({
584
+ ...tc,
585
+ parsedArgs: JSON.parse(tc.arguments),
586
+ ...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
587
+ });
588
+ }
589
+ catch {
590
+ parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
591
+ }
501
592
  }
502
- else {
503
- const toolExecution = this.executeTool(tc, cwd, abortSignal, (update) => updateQueue.push(update));
504
- let settled = false;
505
- let resolved;
506
- let rejected;
507
- void toolExecution
508
- .then((value) => {
509
- resolved = value;
510
- })
511
- .catch((error) => {
512
- rejected = error;
513
- })
514
- .finally(() => {
515
- settled = true;
516
- updateQueue.wake();
593
+ const executedResults = [];
594
+ const appendCancelledToolMessages = (startIndex) => {
595
+ for (let pendingIndex = startIndex; pendingIndex < parsedCalls.length; pendingIndex++) {
596
+ const pending = parsedCalls[pendingIndex];
597
+ const pendingResult = cancelledToolResult(pending.name);
598
+ this.appendMessage({
599
+ role: "tool",
600
+ toolCallId: pending.id,
601
+ content: pendingResult.content,
602
+ metadata: pendingResult.metadata,
603
+ isError: pendingResult.isError,
604
+ });
605
+ executedResults.push(pendingResult);
606
+ }
607
+ };
608
+ for (let index = 0; index < parsedCalls.length; index++) {
609
+ if (abortSignal?.aborted) {
610
+ appendCancelledToolMessages(index);
611
+ throwIfAborted(abortSignal);
612
+ }
613
+ let tc = parsedCalls[index];
614
+ let blockedResult;
615
+ await hookBus.runBeforeToolCall({
616
+ agent: this,
617
+ cwd,
618
+ input: userInput,
619
+ state: hookState,
620
+ queueReminder,
621
+ flushReminders: flushGovernorReminders,
622
+ toolCall: tc,
623
+ blockedResult,
624
+ replaceToolCall: (toolCall) => {
625
+ tc = toolCall;
626
+ },
627
+ blockToolCall: (result) => {
628
+ blockedResult = result;
629
+ },
517
630
  });
518
- while (!settled || updateQueue.hasItems()) {
519
- for (const update of updateQueue.drain()) {
520
- yield { type: "tool_update", id: tc.id, name: tc.name, update };
631
+ assistantMsg.toolCalls[index] = {
632
+ id: tc.id,
633
+ name: tc.name,
634
+ arguments: tc.arguments,
635
+ };
636
+ flushGovernorReminders();
637
+ const toolStartedAt = Date.now();
638
+ traceEvent("tool_execute_start", {
639
+ id: tc.id,
640
+ name: tc.name,
641
+ args: summarizeTraceValue(tc.parsedArgs),
642
+ argsCorrupt: tc.argsCorrupt,
643
+ }, traceContext);
644
+ yield emit({ type: "tool_start", id: tc.id, name: tc.name, args: tc.parsedArgs });
645
+ const todosVersionBefore = this._todosVersion;
646
+ const modeVersionBefore = this._modeVersion;
647
+ const updateQueue = createUpdateQueue();
648
+ let result;
649
+ if (blockedResult) {
650
+ result = blockedResult;
651
+ }
652
+ else {
653
+ const toolExecution = this.executeTool(tc, cwd, abortSignal, (update) => updateQueue.push(update));
654
+ let settled = false;
655
+ let cancelledByAbort = false;
656
+ let resolved;
657
+ let rejected;
658
+ void toolExecution
659
+ .then((value) => {
660
+ resolved = value;
661
+ })
662
+ .catch((error) => {
663
+ rejected = error;
664
+ })
665
+ .finally(() => {
666
+ settled = true;
667
+ updateQueue.wake();
668
+ });
669
+ while (!settled || updateQueue.hasItems()) {
670
+ for (const update of updateQueue.drain()) {
671
+ yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
672
+ }
673
+ for (const update of this.drainSubagentToolUpdates())
674
+ yield emit(update);
675
+ if (!settled) {
676
+ const waitStatus = await updateQueue.wait(abortSignal);
677
+ if (waitStatus === "aborted" && !settled) {
678
+ cancelledByAbort = true;
679
+ break;
680
+ }
681
+ }
521
682
  }
522
- for (const update of this.drainSubagentToolUpdates())
523
- yield update;
524
- if (!settled) {
525
- await updateQueue.wait();
683
+ if (cancelledByAbort) {
684
+ result = cancelledToolResult(tc.name);
526
685
  }
686
+ else {
687
+ if (rejected)
688
+ throw rejected;
689
+ result = resolved ?? { content: `Error: Tool "${tc.name}" returned no result`, isError: true };
690
+ }
691
+ }
692
+ await hookBus.runAfterToolCall({
693
+ agent: this,
694
+ cwd,
695
+ input: userInput,
696
+ state: hookState,
697
+ queueReminder,
698
+ flushReminders: flushGovernorReminders,
699
+ toolCall: tc,
700
+ result,
701
+ replaceResult: (next) => {
702
+ result = next;
703
+ },
704
+ });
705
+ // Honor the model's server-declared per-tool-output token cap (e.g.
706
+ // gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
707
+ // blow past the input window even though our local estimate looks fine.
708
+ const truncatedOutput = truncateToolOutputForModel(result.content, this.providerId, this.apiModel);
709
+ traceEvent("tool_execute_end", {
710
+ id: tc.id,
711
+ name: tc.name,
712
+ elapsedMs: Date.now() - toolStartedAt,
713
+ result: summarizeTraceToolResult(result),
714
+ outputTruncation: {
715
+ truncated: truncatedOutput.truncated,
716
+ originalTokens: truncatedOutput.originalTokens,
717
+ finalTokens: truncatedOutput.finalTokens,
718
+ limit: truncatedOutput.limit,
719
+ },
720
+ }, traceContext);
721
+ this.appendMessage({
722
+ role: "tool",
723
+ toolCallId: tc.id,
724
+ content: truncatedOutput.content,
725
+ metadata: result.metadata,
726
+ isError: result.isError,
727
+ });
728
+ this.compactResidentHistory();
729
+ flushGovernorReminders();
730
+ this.onToolResult?.(tc.name, result);
731
+ executedResults.push(result);
732
+ yield emit({ type: "tool_end", id: tc.id, name: tc.name, result });
733
+ for (const update of this.drainSubagentToolUpdates())
734
+ yield emit(update);
735
+ if (this._todosVersion !== todosVersionBefore) {
736
+ yield emit({ type: "todos_updated", todos: this.getTodos() });
737
+ }
738
+ if (this._modeVersion !== modeVersionBefore) {
739
+ yield emit({ type: "mode_changed", mode: this._mode });
740
+ }
741
+ if (abortSignal?.aborted) {
742
+ appendCancelledToolMessages(index + 1);
743
+ throwIfAborted(abortSignal);
527
744
  }
528
- if (rejected)
529
- throw rejected;
530
- result = resolved ?? { content: `Error: Tool "${tc.name}" returned no result`, isError: true };
531
745
  }
532
- throwIfAborted(abortSignal);
533
- await hookBus.runAfterToolCall({
746
+ await hookBus.runBeforeContinuation({
534
747
  agent: this,
535
748
  cwd,
536
749
  input: userInput,
537
750
  state: hookState,
538
751
  queueReminder,
539
752
  flushReminders: flushGovernorReminders,
540
- toolCall: tc,
541
- result,
542
- replaceResult: (next) => {
543
- result = next;
753
+ toolCalls: parsedCalls,
754
+ toolResults: executedResults,
755
+ requestTextOnlyTurn: (reason) => {
756
+ hookState.forceTextOnlyReason = reason;
544
757
  },
545
758
  });
546
- // Honor the model's server-declared per-tool-output token cap (e.g.
547
- // gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
548
- // blow past the input window even though our local estimate looks fine.
549
- const truncatedOutput = truncateToolOutputForModel(result.content, this.providerId, this.apiModel);
550
- this.appendMessage({
551
- role: "tool",
552
- toolCallId: tc.id,
553
- content: truncatedOutput.content,
554
- metadata: result.metadata,
555
- isError: result.isError,
556
- });
557
- this.compactResidentHistory();
558
759
  flushGovernorReminders();
559
- this.onToolResult?.(tc.name, result);
560
- executedResults.push(result);
561
- yield { type: "tool_end", id: tc.id, name: tc.name, result };
562
- for (const update of this.drainSubagentToolUpdates())
563
- yield update;
564
- if (this._todosVersion !== todosVersionBefore) {
565
- yield { type: "todos_updated", todos: this.getTodos() };
566
- }
567
- if (this._modeVersion !== modeVersionBefore) {
568
- yield { type: "mode_changed", mode: this._mode };
569
- }
760
+ yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
761
+ // Auto-continue: if we have tool results, the LLM needs to respond to them.
762
+ // Emitting the turn boundary keeps UI renderers aligned with the persisted
763
+ // assistant/tool message sequence instead of merging the next answer into
764
+ // the tool-call turn.
765
+ continue;
570
766
  }
571
- await hookBus.runBeforeContinuation({
767
+ await hookBus.runAfterTurn({
572
768
  agent: this,
573
769
  cwd,
574
770
  input: userInput,
575
771
  state: hookState,
576
772
  queueReminder,
577
773
  flushReminders: flushGovernorReminders,
578
- toolCalls: parsedCalls,
579
- toolResults: executedResults,
580
- requestTextOnlyTurn: (reason) => {
581
- hookState.forceTextOnlyReason = reason;
582
- },
583
774
  });
584
775
  flushGovernorReminders();
585
- yield { type: "turn_end", usage: turnUsage, willContinue: true };
586
- // Auto-continue: if we have tool results, the LLM needs to respond to them.
587
- // Emitting the turn boundary keeps UI renderers aligned with the persisted
588
- // assistant/tool message sequence instead of merging the next answer into
589
- // the tool-call turn.
590
- continue;
776
+ const willContinue = !!hookState.forceContinuationReason;
777
+ yield emit({ type: "turn_end", usage: turnUsage, willContinue });
778
+ if (willContinue) {
779
+ delete hookState.forceContinuationReason;
780
+ continue;
781
+ }
782
+ for (const event of rejectPendingInputs("no_continuation"))
783
+ yield emit(event);
784
+ break;
591
785
  }
592
- await hookBus.runAfterTurn({
593
- agent: this,
594
- cwd,
595
- input: userInput,
596
- state: hookState,
597
- queueReminder,
598
- flushReminders: flushGovernorReminders,
599
- });
600
- flushGovernorReminders();
601
- const willContinue = !!hookState.forceContinuationReason;
602
- yield { type: "turn_end", usage: turnUsage, willContinue };
603
- if (willContinue) {
604
- delete hookState.forceContinuationReason;
605
- continue;
786
+ for (const update of this.drainSubagentToolUpdates())
787
+ yield emit(update);
788
+ await stopOwnedAutoServers();
789
+ yield emit({ type: "agent_end" });
790
+ }
791
+ catch (error) {
792
+ if (isAbortError(error, abortSignal)) {
793
+ const appendedBoundary = this.appendInterruptedAssistantBoundary(currentAssistantMsg, currentAssistantAppended);
794
+ const clearedTodos = this.clearTodosAfterInterruptedRun();
795
+ traceEvent("agent_run_interrupted", {
796
+ appendedBoundary,
797
+ clearedTodos,
798
+ messageCount: this.messages.length,
799
+ }, traceContext);
800
+ if (clearedTodos) {
801
+ yield emit({ type: "todos_updated", todos: this.getTodos() });
802
+ }
606
803
  }
607
- break;
804
+ throw error;
805
+ }
806
+ finally {
807
+ await stopOwnedAutoServers();
808
+ traceEvent("agent_run_end", {
809
+ messageCount: this.messages.length,
810
+ }, traceContext);
608
811
  }
609
- for (const update of this.drainSubagentToolUpdates())
610
- yield update;
611
- yield { type: "agent_end" };
612
812
  }
613
813
  async recoverFromOverflow(attempt) {
614
814
  const before = this.messages.length;
@@ -1236,8 +1436,43 @@ export class Agent {
1236
1436
  }
1237
1437
  appendMessage(message) {
1238
1438
  this.messages.push(message);
1439
+ traceEvent("agent_message_append", {
1440
+ message: summarizeTraceMessage(message),
1441
+ messageCount: this.messages.length,
1442
+ }, {
1443
+ sessionFile: this.sessionID,
1444
+ provider: this._providerId || "none",
1445
+ model: this.apiModel || "none",
1446
+ });
1239
1447
  this.onMessageAppend?.(message);
1240
1448
  }
1449
+ appendInterruptedAssistantBoundary(currentAssistant, currentAssistantAppended) {
1450
+ const last = lastProviderMessage(this.messages);
1451
+ if (last?.role === "assistant" && last.error?.aborted) {
1452
+ return false;
1453
+ }
1454
+ const partialText = !currentAssistantAppended ? currentAssistant?.content.trim() : "";
1455
+ const content = partialText
1456
+ ? `${partialText}\n\n${INTERRUPTED_ASSISTANT_CONTENT}`
1457
+ : INTERRUPTED_ASSISTANT_CONTENT;
1458
+ this.appendMessage({
1459
+ role: "assistant",
1460
+ content,
1461
+ reasoning: !currentAssistantAppended ? currentAssistant?.reasoning : undefined,
1462
+ error: {
1463
+ name: "MessageAbortedError",
1464
+ message: "Assistant response was interrupted by the user.",
1465
+ aborted: true,
1466
+ },
1467
+ });
1468
+ return true;
1469
+ }
1470
+ clearTodosAfterInterruptedRun() {
1471
+ if (this._todos.length === 0)
1472
+ return false;
1473
+ this.setTodos([]);
1474
+ return true;
1475
+ }
1241
1476
  async executeTool(toolCall, cwd, abortSignal, emitUpdate) {
1242
1477
  throwIfAborted(abortSignal);
1243
1478
  if (toolCall.name === "exit_plan_mode" && this._mode !== "plan") {
@@ -1362,9 +1597,61 @@ function throwIfAborted(signal) {
1362
1597
  throw reason;
1363
1598
  throw new AgentAbortError(typeof reason === "string" ? reason : undefined);
1364
1599
  }
1600
+ function isAbortLikeError(error, signal) {
1601
+ if (signal?.aborted)
1602
+ return true;
1603
+ if (error instanceof AgentAbortError)
1604
+ return true;
1605
+ if (error instanceof DOMException && error.name === "AbortError")
1606
+ return true;
1607
+ if (typeof error === "object" && error !== null && error.name === "AbortError")
1608
+ return true;
1609
+ return false;
1610
+ }
1611
+ function isAbortError(error, signal) {
1612
+ return isAbortLikeError(error, signal);
1613
+ }
1614
+ function shouldAppendModelInterruptedBoundary(messages) {
1615
+ return messages.at(-1)?.role === "tool";
1616
+ }
1617
+ function createModelInterruptedMessage(error, metadata) {
1618
+ return {
1619
+ role: "assistant",
1620
+ content: `[model request interrupted before a final answer was produced: ${summarizeInterruptError(error)}]`,
1621
+ model: metadata.model,
1622
+ providerId: metadata.providerId,
1623
+ modelId: metadata.modelId,
1624
+ };
1625
+ }
1626
+ function summarizeInterruptError(error) {
1627
+ const message = error instanceof Error
1628
+ ? error.message
1629
+ : typeof error === "string"
1630
+ ? error
1631
+ : String(error);
1632
+ return message.replace(/\s+/g, " ").trim().slice(0, 240) || "unknown error";
1633
+ }
1634
+ function lastProviderMessage(messages) {
1635
+ for (let i = messages.length - 1; i >= 0; i--) {
1636
+ const message = messages[i];
1637
+ if (message.role === "system" || message.role === "meta")
1638
+ continue;
1639
+ return message;
1640
+ }
1641
+ return undefined;
1642
+ }
1643
+ function cancelledToolResult(toolName) {
1644
+ return {
1645
+ content: `Tool "${toolName}" was cancelled.`,
1646
+ isError: true,
1647
+ status: "cancelled",
1648
+ metadata: { reason: "cancelled" },
1649
+ };
1650
+ }
1365
1651
  function createUpdateQueue() {
1366
1652
  const items = [];
1367
1653
  let waiter;
1654
+ let abortCleanup;
1368
1655
  return {
1369
1656
  push(item) {
1370
1657
  items.push(item);
@@ -1376,17 +1663,36 @@ function createUpdateQueue() {
1376
1663
  hasItems() {
1377
1664
  return items.length > 0;
1378
1665
  },
1379
- wait() {
1666
+ wait(signal) {
1380
1667
  if (items.length > 0)
1381
- return Promise.resolve();
1668
+ return Promise.resolve("woken");
1669
+ if (signal?.aborted)
1670
+ return Promise.resolve("aborted");
1382
1671
  return new Promise((resolve) => {
1672
+ abortCleanup?.();
1673
+ abortCleanup = undefined;
1674
+ const finish = (status) => {
1675
+ if (waiter !== resolve)
1676
+ return;
1677
+ waiter = undefined;
1678
+ abortCleanup?.();
1679
+ abortCleanup = undefined;
1680
+ resolve(status);
1681
+ };
1682
+ if (signal) {
1683
+ const onAbort = () => finish("aborted");
1684
+ signal.addEventListener("abort", onAbort, { once: true });
1685
+ abortCleanup = () => signal.removeEventListener("abort", onAbort);
1686
+ }
1383
1687
  waiter = resolve;
1384
1688
  });
1385
1689
  },
1386
1690
  wake() {
1387
1691
  const resolve = waiter;
1388
1692
  waiter = undefined;
1389
- resolve?.();
1693
+ abortCleanup?.();
1694
+ abortCleanup = undefined;
1695
+ resolve?.("woken");
1390
1696
  },
1391
1697
  };
1392
1698
  }
@@ -1432,21 +1738,26 @@ function sanitizeSubagentSummary(value) {
1432
1738
  return stripProviderProtocolArtifacts(value).trim();
1433
1739
  }
1434
1740
  function needsExplicitFinalSummary(record, executedAnyTool) {
1435
- // If the subagent actually invoked any tool, always solicit an explicit final
1436
- // summary. We cannot tell from the stream alone whether a tool-free trailing
1437
- // turn was the real answer or mid-thought narration ("Let me try X next:").
1438
- // Asking the model to restate its findings is cheap and yields predictable,
1439
- // clean output. (Profile-validation notes in `toolNotes` do not count as
1440
- // actual tool executions.)
1441
- if (executedAnyTool)
1442
- return true;
1443
1741
  if (!record.summary)
1444
- return false;
1742
+ return executedAnyTool;
1445
1743
  if (isOnlyProviderProtocolArtifacts(record.summary))
1446
1744
  return true;
1447
1745
  if (/<\/?[||][^<>]*>/.test(record.summary))
1448
1746
  return true;
1449
- return false;
1747
+ if (!executedAnyTool)
1748
+ return false;
1749
+ if (record.summary === EMPTY_ASSISTANT_FALLBACK)
1750
+ return true;
1751
+ return isLikelyIntermediateSubagentSummary(record.summary);
1752
+ }
1753
+ function isLikelyIntermediateSubagentSummary(value) {
1754
+ const normalized = value.trim().replace(/\s+/g, " ").toLowerCase();
1755
+ if (!normalized)
1756
+ return false;
1757
+ if (/^(let me|i'll|i will|i need to|i should|i'm going to|now i'll|now i will)\b/.test(normalized)) {
1758
+ return true;
1759
+ }
1760
+ return /:\s*$/.test(normalized) && /\b(read|inspect|check|look|search|try|open)\b/.test(normalized);
1450
1761
  }
1451
1762
  function summarizeSubagentToolEnd(event) {
1452
1763
  const metadata = (event.result.metadata ?? {});