@bubblebrain-ai/bubble 0.0.13 → 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 (75) hide show
  1. package/dist/agent/execution-governor.js +1 -1
  2. package/dist/agent/tool-intent.js +1 -0
  3. package/dist/agent.d.ts +2 -0
  4. package/dist/agent.js +589 -316
  5. package/dist/approval/controller.d.ts +1 -0
  6. package/dist/approval/controller.js +20 -3
  7. package/dist/approval/tool-helper.js +2 -0
  8. package/dist/approval/types.d.ts +14 -1
  9. package/dist/context/compact.js +9 -3
  10. package/dist/context/projector.js +27 -12
  11. package/dist/debug-trace.d.ts +27 -0
  12. package/dist/debug-trace.js +385 -0
  13. package/dist/feishu/agent-host/approval-card.js +9 -0
  14. package/dist/feishu/serve.js +7 -1
  15. package/dist/main.js +28 -0
  16. package/dist/model-catalog.js +1 -0
  17. package/dist/orchestrator/default-hooks.js +19 -8
  18. package/dist/orchestrator/hooks.d.ts +1 -0
  19. package/dist/prompt/environment.js +2 -0
  20. package/dist/prompt/reminders.d.ts +5 -6
  21. package/dist/prompt/reminders.js +8 -9
  22. package/dist/prompt/runtime.js +2 -2
  23. package/dist/provider-openai-codex.d.ts +7 -0
  24. package/dist/provider-openai-codex.js +265 -124
  25. package/dist/provider-registry.d.ts +2 -0
  26. package/dist/provider-registry.js +58 -9
  27. package/dist/provider.d.ts +3 -0
  28. package/dist/provider.js +5 -1
  29. package/dist/session-log.js +13 -1
  30. package/dist/slash-commands/commands.js +12 -0
  31. package/dist/slash-commands/types.d.ts +2 -0
  32. package/dist/stats/usage.d.ts +52 -0
  33. package/dist/stats/usage.js +414 -0
  34. package/dist/tools/apply-patch.d.ts +9 -0
  35. package/dist/tools/apply-patch.js +330 -0
  36. package/dist/tools/bash.js +205 -44
  37. package/dist/tools/edit-apply.d.ts +5 -2
  38. package/dist/tools/edit-apply.js +221 -31
  39. package/dist/tools/edit.js +12 -3
  40. package/dist/tools/file-mutation-queue.d.ts +1 -0
  41. package/dist/tools/file-mutation-queue.js +12 -1
  42. package/dist/tools/index.d.ts +2 -0
  43. package/dist/tools/index.js +7 -1
  44. package/dist/tools/patch-apply.d.ts +41 -0
  45. package/dist/tools/patch-apply.js +312 -0
  46. package/dist/tools/server-manager.d.ts +36 -0
  47. package/dist/tools/server-manager.js +234 -0
  48. package/dist/tools/server.d.ts +6 -0
  49. package/dist/tools/server.js +245 -0
  50. package/dist/tools/write.d.ts +3 -6
  51. package/dist/tools/write.js +26 -46
  52. package/dist/tui/display-history.d.ts +1 -0
  53. package/dist/tui/display-history.js +5 -4
  54. package/dist/tui/edit-diff.js +6 -1
  55. package/dist/tui/model-picker-data.d.ts +10 -0
  56. package/dist/tui/model-picker-data.js +32 -0
  57. package/dist/tui/run.js +632 -89
  58. package/dist/tui/tool-renderers/fallback.js +1 -1
  59. package/dist/tui/tool-renderers/write-preview.js +2 -0
  60. package/dist/tui/trace-groups.js +10 -3
  61. package/dist/tui-ink/app.js +1 -4
  62. package/dist/tui-ink/approval/approval-dialog.js +7 -1
  63. package/dist/tui-ink/display-history.d.ts +1 -0
  64. package/dist/tui-ink/display-history.js +5 -4
  65. package/dist/tui-ink/message-list.js +14 -8
  66. package/dist/tui-ink/trace-groups.js +1 -1
  67. package/dist/tui-opentui/app.js +2 -0
  68. package/dist/tui-opentui/approval/approval-dialog.js +7 -1
  69. package/dist/tui-opentui/display-history.d.ts +1 -0
  70. package/dist/tui-opentui/display-history.js +5 -4
  71. package/dist/tui-opentui/edit-diff.js +6 -1
  72. package/dist/tui-opentui/message-list.js +6 -3
  73. package/dist/tui-opentui/trace-groups.js +10 -3
  74. package/dist/types.d.ts +12 -2
  75. package/package.json +1 -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);
@@ -237,6 +245,23 @@ export class Agent {
237
245
  async *run(userInput, cwd, options = {}) {
238
246
  const abortSignal = composeAbortSignals([options.abortSignal, this.budgetLedger?.signal]);
239
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);
240
265
  throwIfAborted(abortSignal);
241
266
  const hookBus = new HookBus();
242
267
  for (const hooks of createDefaultHooks()) {
@@ -290,7 +315,7 @@ export class Agent {
290
315
  };
291
316
  if (this._todos.length > 0 && this._todos.every((t) => t.status === "completed")) {
292
317
  this.setTodos([]);
293
- yield { type: "todos_updated", todos: [] };
318
+ yield emit({ type: "todos_updated", todos: [] });
294
319
  }
295
320
  this.appendMessage({ role: "user", content: userInput });
296
321
  await hookBus.runBeforeTurn({
@@ -303,350 +328,487 @@ export class Agent {
303
328
  });
304
329
  flushGovernorReminders();
305
330
  let consecutiveOverflowRecoveries = 0;
331
+ let consecutiveEmptyAssistantRecoveries = 0;
306
332
  let step = 0;
307
- while (true) {
308
- throwIfAborted(abortSignal);
309
- flushGovernorReminders();
310
- for (const update of this.drainSubagentToolUpdates())
311
- yield update;
312
- for (const event of applyPendingInputs())
313
- yield event;
314
- yield { type: "turn_start" };
315
- step += 1;
316
- hookState.turnCount = step;
317
- if (this.taskBudget) {
318
- hookState.taskBudget = {
319
- total: this.taskBudget.total,
320
- spent: hookState.taskBudget?.spent ?? 0,
321
- };
322
- }
323
- let forceTextOnlyReason = hookState.forceTextOnlyReason;
324
- if (!forceTextOnlyReason && this.maxTurns !== undefined && step >= this.maxTurns) {
325
- forceTextOnlyReason = "The configured maximum turns for this agent have been reached.";
326
- hookState.forceTextOnlyReason = forceTextOnlyReason;
327
- }
328
- if (forceTextOnlyReason) {
329
- this.injectSystemReminder(buildToolFreezeReminder(forceTextOnlyReason));
330
- }
331
- const assistantMsg = {
332
- role: "assistant",
333
- content: "",
334
- reasoning: "",
335
- toolCalls: [],
336
- };
337
- const streamingToolCalls = new Map();
338
- let turnUsage;
339
- let assistantAppended = false;
340
- let toolEntries = Array.from(this.tools.values())
341
- .filter((t) => !t.deferred || this.unlockedDeferred.has(t.name));
342
- const beforeModelCallCtx = {
343
- agent: this,
344
- cwd,
345
- input: userInput,
346
- state: hookState,
347
- queueReminder,
348
- flushReminders: flushGovernorReminders,
349
- toolEntries,
350
- disableTools: (reason) => {
351
- hookState.forceTextOnlyReason = reason;
352
- },
353
- };
354
- await hookBus.runBeforeModelCall(beforeModelCallCtx);
355
- toolEntries = beforeModelCallCtx.toolEntries;
356
- if (this._mode !== "plan") {
357
- toolEntries = toolEntries.filter((t) => t.name !== "exit_plan_mode");
358
- }
359
- flushGovernorReminders();
360
- const toolDefinitions = ((hookState.forceTextOnlyReason ? [] : toolEntries))
361
- .map((t) => ({
362
- name: t.name,
363
- description: t.description,
364
- parameters: t.parameters,
365
- }));
366
- // LLM-driven compaction runs ahead of projector's algorithmic passes. If
367
- // it succeeds, this.messages is replaced with [preserved system+meta] +
368
- // [LLM summary] + [last user msg], and the projector becomes a no-op for
369
- // budget. If it fails (network error, etc.), the projector's existing
370
- // algorithmic fallback still kicks in.
371
- await this.maybeCompactWithLLM();
372
- try {
373
- const projectedMessages = projectMessages(this.messages, {
374
- 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,
375
373
  providerId: this.providerId,
376
374
  modelId: this.apiModel,
377
- usageAnchorTokens: this.lastInputTokens ?? undefined,
378
- anchorMessageCount: this.lastAnchorMessageCount ?? undefined,
379
- });
380
- const stream = this.provider.streamChat(projectedMessages, {
381
- model: this.apiModel,
382
- tools: toolDefinitions,
383
- temperature: this.temperature,
384
- thinkingLevel: this.thinkingLevel,
385
- abortSignal,
386
- });
387
- for await (const chunk of stream) {
388
- throwIfAborted(abortSignal);
389
- switch (chunk.type) {
390
- case "text":
391
- assistantMsg.content += chunk.content;
392
- yield { type: "text_delta", content: chunk.content };
393
- break;
394
- case "reasoning_delta":
395
- debugReasoningStream({
396
- stage: "agent_receive",
397
- providerId: this._providerId,
398
- modelId: this.apiModel,
399
- turnStep: step,
400
- beforeLength: assistantMsg.reasoning?.length ?? 0,
401
- delta: summarizeDebugText(chunk.content),
402
- afterLength: (assistantMsg.reasoning?.length ?? 0) + chunk.content.length,
403
- });
404
- assistantMsg.reasoning = (assistantMsg.reasoning || "") + chunk.content;
405
- yield { type: "reasoning_delta", content: chunk.content };
406
- break;
407
- case "tool_call":
408
- if (chunk.isStart) {
409
- streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
410
- yield { type: "tool_call_start", id: chunk.id, name: chunk.name };
411
- }
412
- if (!streamingToolCalls.has(chunk.id)) {
413
- streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
414
- }
415
- const currentToolCall = streamingToolCalls.get(chunk.id);
416
- if (currentToolCall) {
417
- currentToolCall.name = chunk.name || currentToolCall.name;
418
- currentToolCall.args += chunk.arguments;
419
- if (chunk.argumentsFull !== undefined) {
420
- 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 });
465
+ }
466
+ if (!streamingToolCalls.has(chunk.id)) {
467
+ streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
421
468
  }
422
- if (chunk.argumentsCorrupt) {
423
- currentToolCall.argsCorrupt = true;
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
+ }
424
489
  }
425
- if (chunk.arguments) {
426
- yield {
427
- type: "tool_call_delta",
490
+ if (chunk.isEnd && currentToolCall) {
491
+ assistantMsg.toolCalls.push({
492
+ id: currentToolCall.id,
493
+ name: currentToolCall.name,
494
+ arguments: currentToolCall.args,
495
+ ...(currentToolCall.argsCorrupt ? { argsCorrupt: true } : {}),
496
+ });
497
+ yield emit({
498
+ type: "tool_call_end",
428
499
  id: currentToolCall.id,
429
500
  name: currentToolCall.name,
430
- argumentsDelta: chunk.arguments,
431
501
  arguments: currentToolCall.args,
432
- };
502
+ });
503
+ streamingToolCalls.delete(chunk.id);
433
504
  }
434
- }
435
- if (chunk.isEnd && currentToolCall) {
436
- assistantMsg.toolCalls.push({
437
- id: currentToolCall.id,
438
- name: currentToolCall.name,
439
- arguments: currentToolCall.args,
440
- ...(currentToolCall.argsCorrupt ? { argsCorrupt: true } : {}),
441
- });
442
- yield {
443
- type: "tool_call_end",
444
- id: currentToolCall.id,
445
- name: currentToolCall.name,
446
- arguments: currentToolCall.args,
447
- };
448
- streamingToolCalls.delete(chunk.id);
449
- }
450
- break;
451
- case "usage":
452
- turnUsage = chunk.usage;
453
- this.budgetLedger?.recordUsage(chunk.usage, this.budgetSource);
454
- this.lastInputTokens = chunk.usage.promptTokens;
455
- this.lastAnchorMessageCount = this.messages.length;
456
- if (hookState.taskBudget) {
457
- hookState.taskBudget.spent += chunk.usage.promptTokens + chunk.usage.completionTokens;
458
- if (hookState.taskBudget.spent >= hookState.taskBudget.total) {
459
- 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
+ }
460
517
  }
461
- }
462
- break;
518
+ break;
519
+ }
520
+ for (const update of this.drainSubagentToolUpdates())
521
+ yield emit(update);
463
522
  }
464
- for (const update of this.drainSubagentToolUpdates())
465
- yield update;
466
- }
467
- throwIfAborted(abortSignal);
468
- this.appendMessage(assistantMsg);
469
- assistantAppended = true;
470
- }
471
- catch (error) {
472
- if (assistantAppended) {
473
- throw error;
474
- }
475
- if (!isContextOverflowError(error)) {
476
- throw error;
477
- }
478
- if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
479
- 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;
480
548
  }
481
- const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
482
- consecutiveOverflowRecoveries += 1;
483
- yield { type: "context_recovered", droppedMessages, reason: "overflow" };
484
- continue;
485
- }
486
- consecutiveOverflowRecoveries = 0;
487
- // Execute tools if any
488
- if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
489
- const parsedCalls = [];
490
- for (let index = 0; index < assistantMsg.toolCalls.length; index++) {
491
- const tc = assistantMsg.toolCalls[index];
492
- try {
493
- parsedCalls.push({
494
- ...tc,
495
- parsedArgs: JSON.parse(tc.arguments),
496
- ...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
497
- });
549
+ catch (error) {
550
+ traceEvent("provider_stream_error", {
551
+ error: summarizeTraceError(error),
552
+ }, traceContext);
553
+ if (assistantAppended) {
554
+ throw error;
555
+ }
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;
498
566
  }
499
- catch {
500
- parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
567
+ if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
568
+ throw error;
501
569
  }
570
+ const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
571
+ consecutiveOverflowRecoveries += 1;
572
+ yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
573
+ continue;
502
574
  }
503
- const executedResults = [];
504
- for (let index = 0; index < parsedCalls.length; index++) {
505
- throwIfAborted(abortSignal);
506
- let tc = parsedCalls[index];
507
- let blockedResult;
508
- await hookBus.runBeforeToolCall({
509
- agent: this,
510
- cwd,
511
- input: userInput,
512
- state: hookState,
513
- queueReminder,
514
- flushReminders: flushGovernorReminders,
515
- toolCall: tc,
516
- blockedResult,
517
- replaceToolCall: (toolCall) => {
518
- tc = toolCall;
519
- },
520
- blockToolCall: (result) => {
521
- blockedResult = result;
522
- },
523
- });
524
- assistantMsg.toolCalls[index] = {
525
- id: tc.id,
526
- name: tc.name,
527
- arguments: tc.arguments,
528
- };
529
- flushGovernorReminders();
530
- yield { type: "tool_start", id: tc.id, name: tc.name, args: tc.parsedArgs };
531
- const todosVersionBefore = this._todosVersion;
532
- const modeVersionBefore = this._modeVersion;
533
- const updateQueue = createUpdateQueue();
534
- let result;
535
- if (blockedResult) {
536
- 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
+ }
537
592
  }
538
- else {
539
- const toolExecution = this.executeTool(tc, cwd, abortSignal, (update) => updateQueue.push(update));
540
- let settled = false;
541
- let resolved;
542
- let rejected;
543
- void toolExecution
544
- .then((value) => {
545
- resolved = value;
546
- })
547
- .catch((error) => {
548
- rejected = error;
549
- })
550
- .finally(() => {
551
- settled = true;
552
- 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
+ },
553
630
  });
554
- while (!settled || updateQueue.hasItems()) {
555
- for (const update of updateQueue.drain()) {
556
- 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
+ }
557
682
  }
558
- for (const update of this.drainSubagentToolUpdates())
559
- yield update;
560
- if (!settled) {
561
- await updateQueue.wait();
683
+ if (cancelledByAbort) {
684
+ result = cancelledToolResult(tc.name);
562
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);
563
744
  }
564
- if (rejected)
565
- throw rejected;
566
- result = resolved ?? { content: `Error: Tool "${tc.name}" returned no result`, isError: true };
567
745
  }
568
- throwIfAborted(abortSignal);
569
- await hookBus.runAfterToolCall({
746
+ await hookBus.runBeforeContinuation({
570
747
  agent: this,
571
748
  cwd,
572
749
  input: userInput,
573
750
  state: hookState,
574
751
  queueReminder,
575
752
  flushReminders: flushGovernorReminders,
576
- toolCall: tc,
577
- result,
578
- replaceResult: (next) => {
579
- result = next;
753
+ toolCalls: parsedCalls,
754
+ toolResults: executedResults,
755
+ requestTextOnlyTurn: (reason) => {
756
+ hookState.forceTextOnlyReason = reason;
580
757
  },
581
758
  });
582
- // Honor the model's server-declared per-tool-output token cap (e.g.
583
- // gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
584
- // blow past the input window even though our local estimate looks fine.
585
- const truncatedOutput = truncateToolOutputForModel(result.content, this.providerId, this.apiModel);
586
- this.appendMessage({
587
- role: "tool",
588
- toolCallId: tc.id,
589
- content: truncatedOutput.content,
590
- metadata: result.metadata,
591
- isError: result.isError,
592
- });
593
- this.compactResidentHistory();
594
759
  flushGovernorReminders();
595
- this.onToolResult?.(tc.name, result);
596
- executedResults.push(result);
597
- yield { type: "tool_end", id: tc.id, name: tc.name, result };
598
- for (const update of this.drainSubagentToolUpdates())
599
- yield update;
600
- if (this._todosVersion !== todosVersionBefore) {
601
- yield { type: "todos_updated", todos: this.getTodos() };
602
- }
603
- if (this._modeVersion !== modeVersionBefore) {
604
- yield { type: "mode_changed", mode: this._mode };
605
- }
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;
606
766
  }
607
- await hookBus.runBeforeContinuation({
767
+ await hookBus.runAfterTurn({
608
768
  agent: this,
609
769
  cwd,
610
770
  input: userInput,
611
771
  state: hookState,
612
772
  queueReminder,
613
773
  flushReminders: flushGovernorReminders,
614
- toolCalls: parsedCalls,
615
- toolResults: executedResults,
616
- requestTextOnlyTurn: (reason) => {
617
- hookState.forceTextOnlyReason = reason;
618
- },
619
774
  });
620
775
  flushGovernorReminders();
621
- yield { type: "turn_end", usage: turnUsage, willContinue: true };
622
- // Auto-continue: if we have tool results, the LLM needs to respond to them.
623
- // Emitting the turn boundary keeps UI renderers aligned with the persisted
624
- // assistant/tool message sequence instead of merging the next answer into
625
- // the tool-call turn.
626
- 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;
627
785
  }
628
- await hookBus.runAfterTurn({
629
- agent: this,
630
- cwd,
631
- input: userInput,
632
- state: hookState,
633
- queueReminder,
634
- flushReminders: flushGovernorReminders,
635
- });
636
- flushGovernorReminders();
637
- const willContinue = !!hookState.forceContinuationReason;
638
- yield { type: "turn_end", usage: turnUsage, willContinue };
639
- if (willContinue) {
640
- delete hookState.forceContinuationReason;
641
- 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
+ }
642
803
  }
643
- for (const event of rejectPendingInputs("no_continuation"))
644
- yield event;
645
- break;
804
+ throw error;
805
+ }
806
+ finally {
807
+ await stopOwnedAutoServers();
808
+ traceEvent("agent_run_end", {
809
+ messageCount: this.messages.length,
810
+ }, traceContext);
646
811
  }
647
- for (const update of this.drainSubagentToolUpdates())
648
- yield update;
649
- yield { type: "agent_end" };
650
812
  }
651
813
  async recoverFromOverflow(attempt) {
652
814
  const before = this.messages.length;
@@ -1274,8 +1436,43 @@ export class Agent {
1274
1436
  }
1275
1437
  appendMessage(message) {
1276
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
+ });
1277
1447
  this.onMessageAppend?.(message);
1278
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
+ }
1279
1476
  async executeTool(toolCall, cwd, abortSignal, emitUpdate) {
1280
1477
  throwIfAborted(abortSignal);
1281
1478
  if (toolCall.name === "exit_plan_mode" && this._mode !== "plan") {
@@ -1400,9 +1597,61 @@ function throwIfAborted(signal) {
1400
1597
  throw reason;
1401
1598
  throw new AgentAbortError(typeof reason === "string" ? reason : undefined);
1402
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
+ }
1403
1651
  function createUpdateQueue() {
1404
1652
  const items = [];
1405
1653
  let waiter;
1654
+ let abortCleanup;
1406
1655
  return {
1407
1656
  push(item) {
1408
1657
  items.push(item);
@@ -1414,17 +1663,36 @@ function createUpdateQueue() {
1414
1663
  hasItems() {
1415
1664
  return items.length > 0;
1416
1665
  },
1417
- wait() {
1666
+ wait(signal) {
1418
1667
  if (items.length > 0)
1419
- return Promise.resolve();
1668
+ return Promise.resolve("woken");
1669
+ if (signal?.aborted)
1670
+ return Promise.resolve("aborted");
1420
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
+ }
1421
1687
  waiter = resolve;
1422
1688
  });
1423
1689
  },
1424
1690
  wake() {
1425
1691
  const resolve = waiter;
1426
1692
  waiter = undefined;
1427
- resolve?.();
1693
+ abortCleanup?.();
1694
+ abortCleanup = undefined;
1695
+ resolve?.("woken");
1428
1696
  },
1429
1697
  };
1430
1698
  }
@@ -1470,21 +1738,26 @@ function sanitizeSubagentSummary(value) {
1470
1738
  return stripProviderProtocolArtifacts(value).trim();
1471
1739
  }
1472
1740
  function needsExplicitFinalSummary(record, executedAnyTool) {
1473
- // If the subagent actually invoked any tool, always solicit an explicit final
1474
- // summary. We cannot tell from the stream alone whether a tool-free trailing
1475
- // turn was the real answer or mid-thought narration ("Let me try X next:").
1476
- // Asking the model to restate its findings is cheap and yields predictable,
1477
- // clean output. (Profile-validation notes in `toolNotes` do not count as
1478
- // actual tool executions.)
1479
- if (executedAnyTool)
1480
- return true;
1481
1741
  if (!record.summary)
1482
- return false;
1742
+ return executedAnyTool;
1483
1743
  if (isOnlyProviderProtocolArtifacts(record.summary))
1484
1744
  return true;
1485
1745
  if (/<\/?[||][^<>]*>/.test(record.summary))
1486
1746
  return true;
1487
- 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);
1488
1761
  }
1489
1762
  function summarizeSubagentToolEnd(event) {
1490
1763
  const metadata = (event.result.metadata ?? {});