@bubblebrain-ai/bubble 0.0.4 → 0.0.5

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 (77) hide show
  1. package/dist/agent/budget-ledger.d.ts +20 -0
  2. package/dist/agent/budget-ledger.js +51 -0
  3. package/dist/agent/execution-governor.js +1 -1
  4. package/dist/agent/profiles.d.ts +59 -0
  5. package/dist/agent/profiles.js +460 -0
  6. package/dist/agent/subagent-control.d.ts +52 -0
  7. package/dist/agent/subagent-control.js +38 -0
  8. package/dist/agent.d.ts +60 -1
  9. package/dist/agent.js +602 -53
  10. package/dist/context/budget.js +1 -0
  11. package/dist/context/compact-llm.js +7 -6
  12. package/dist/context/compact.js +6 -6
  13. package/dist/context/projector.d.ts +3 -3
  14. package/dist/context/projector.js +32 -18
  15. package/dist/context/prune.d.ts +2 -2
  16. package/dist/context/prune.js +1 -4
  17. package/dist/main.js +12 -5
  18. package/dist/mcp/manager.js +1 -0
  19. package/dist/orchestrator/default-hooks.js +48 -9
  20. package/dist/orchestrator/hooks.d.ts +5 -0
  21. package/dist/prompt/compose.d.ts +1 -0
  22. package/dist/prompt/compose.js +8 -1
  23. package/dist/prompt/environment.js +21 -2
  24. package/dist/prompt/reminders.d.ts +3 -1
  25. package/dist/prompt/reminders.js +23 -4
  26. package/dist/prompt/runtime.d.ts +1 -1
  27. package/dist/prompt/runtime.js +1 -1
  28. package/dist/provider-artifacts.d.ts +7 -0
  29. package/dist/provider-artifacts.js +60 -0
  30. package/dist/provider.d.ts +6 -7
  31. package/dist/provider.js +77 -15
  32. package/dist/session-log.js +3 -1
  33. package/dist/system-prompt.d.ts +2 -0
  34. package/dist/tools/agent-lifecycle.d.ts +6 -0
  35. package/dist/tools/agent-lifecycle.js +355 -0
  36. package/dist/tools/bash.js +2 -0
  37. package/dist/tools/edit-apply.d.ts +25 -0
  38. package/dist/tools/edit-apply.js +197 -0
  39. package/dist/tools/edit.js +63 -56
  40. package/dist/tools/exit-plan-mode.js +3 -1
  41. package/dist/tools/file-mutation-queue.d.ts +1 -0
  42. package/dist/tools/file-mutation-queue.js +32 -0
  43. package/dist/tools/glob.js +1 -0
  44. package/dist/tools/grep.js +1 -0
  45. package/dist/tools/index.d.ts +1 -1
  46. package/dist/tools/index.js +3 -3
  47. package/dist/tools/lsp.js +2 -0
  48. package/dist/tools/memory.js +2 -0
  49. package/dist/tools/question.js +2 -0
  50. package/dist/tools/read.js +1 -0
  51. package/dist/tools/skill.js +1 -0
  52. package/dist/tools/task.js +1 -0
  53. package/dist/tools/todo.js +1 -0
  54. package/dist/tools/tool-search.js +2 -1
  55. package/dist/tools/web-fetch.js +1 -0
  56. package/dist/tools/web-search.js +1 -0
  57. package/dist/tools/write.js +2 -0
  58. package/dist/tui/display-history.d.ts +8 -1
  59. package/dist/tui/markdown-inline.d.ts +22 -0
  60. package/dist/tui/markdown-inline.js +68 -0
  61. package/dist/tui/render-signature.d.ts +1 -0
  62. package/dist/tui/render-signature.js +7 -0
  63. package/dist/tui/run.js +712 -267
  64. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  65. package/dist/tui/tool-renderers/fallback.js +75 -0
  66. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  67. package/dist/tui/tool-renderers/registry.js +11 -0
  68. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  69. package/dist/tui/tool-renderers/subagent.js +114 -0
  70. package/dist/tui/tool-renderers/types.d.ts +36 -0
  71. package/dist/tui/tool-renderers/types.js +1 -0
  72. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  73. package/dist/tui/tool-renderers/write-preview.js +22 -0
  74. package/dist/tui/tool-renderers/write.d.ts +6 -0
  75. package/dist/tui/tool-renderers/write.js +82 -0
  76. package/dist/types.d.ts +90 -10
  77. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * It maintains message state, calls the LLM, executes tools, and auto-continues.
4
4
  */
5
5
  import { compactMessages } from "./context/compact.js";
6
+ import { randomUUID } from "node:crypto";
6
7
  import { compactMessagesWithLLM } from "./context/compact-llm.js";
7
8
  import { getContextBudget } from "./context/budget.js";
8
9
  import { isContextOverflowError } from "./context/overflow.js";
@@ -11,7 +12,12 @@ import { aggressivePruneMessages } from "./context/prune.js";
11
12
  import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
12
13
  import { HookBus } from "./orchestrator/hooks.js";
13
14
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
14
- import { filterToolsForSubtask, getSubtaskPolicy } from "./agent/subtask-policy.js";
15
+ import { getSubtaskPolicy } from "./agent/subtask-policy.js";
16
+ import { composeAbortSignals } from "./agent/budget-ledger.js";
17
+ import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
18
+ import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
19
+ import { buildSystemPrompt } from "./system-prompt.js";
20
+ import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
15
21
  const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
16
22
  const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
17
23
  const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
@@ -46,6 +52,12 @@ export class Agent {
46
52
  hookDefinitions;
47
53
  maxTurns;
48
54
  taskBudget;
55
+ budgetLedger;
56
+ budgetSource;
57
+ skillSummaries;
58
+ memoryPrompt;
59
+ subagentThreads = new Map();
60
+ pendingSubagentUpdates = [];
49
61
  lastInputTokens = null;
50
62
  lastAnchorMessageCount = null;
51
63
  constructor(options) {
@@ -64,6 +76,10 @@ export class Agent {
64
76
  this.hookDefinitions = options.hooks ?? [];
65
77
  this.maxTurns = options.maxTurns ?? options.steps;
66
78
  this.taskBudget = options.taskBudget;
79
+ this.budgetLedger = options.budgetLedger;
80
+ this.budgetSource = options.budgetSource ?? { runId: this.sessionID ?? "agent" };
81
+ this.skillSummaries = options.skills ?? [];
82
+ this.memoryPrompt = options.memoryPrompt;
67
83
  if (options.systemPrompt) {
68
84
  this.messages.push({ role: "system", content: options.systemPrompt });
69
85
  }
@@ -101,11 +117,11 @@ export class Agent {
101
117
  return !!tool?.deferred && !this.unlockedDeferred.has(name);
102
118
  }
103
119
  injectSystemReminder(content) {
104
- this.appendMessage({ role: "user", content, isMeta: true });
120
+ this.appendMessage({ role: "meta", kind: "system-reminder", content });
105
121
  }
106
122
  injectModeReminder() {
107
- this.messages = this.messages.filter((message) => !(message.role === "user"
108
- && message.isMeta
123
+ this.messages = this.messages.filter((message) => !(message.role === "meta"
124
+ && message.kind === "system-reminder"
109
125
  && isPermissionModeReminder(message.content)));
110
126
  this.injectSystemReminder(reminderForMode(this._mode));
111
127
  }
@@ -131,7 +147,7 @@ export class Agent {
131
147
  this.provider = provider;
132
148
  }
133
149
  complete(messages, options) {
134
- return this.provider.complete(messages, {
150
+ return this.provider.complete(projectMessages(messages), {
135
151
  model: options?.model ?? this.apiModel,
136
152
  temperature: options?.temperature ?? this.temperature,
137
153
  thinkingLevel: options?.thinkingLevel ?? this.thinkingLevel,
@@ -189,7 +205,7 @@ export class Agent {
189
205
  this.messages.unshift(systemMessage);
190
206
  }
191
207
  async *run(userInput, cwd, options = {}) {
192
- const abortSignal = options.abortSignal;
208
+ const abortSignal = composeAbortSignals([options.abortSignal, this.budgetLedger?.signal]);
193
209
  throwIfAborted(abortSignal);
194
210
  const hookBus = new HookBus();
195
211
  for (const hooks of createDefaultHooks()) {
@@ -227,6 +243,8 @@ export class Agent {
227
243
  while (true) {
228
244
  throwIfAborted(abortSignal);
229
245
  flushGovernorReminders();
246
+ for (const update of this.drainSubagentToolUpdates())
247
+ yield update;
230
248
  yield { type: "turn_start" };
231
249
  step += 1;
232
250
  hookState.turnCount = step;
@@ -250,7 +268,7 @@ export class Agent {
250
268
  reasoning: "",
251
269
  toolCalls: [],
252
270
  };
253
- let currentToolCall = null;
271
+ const streamingToolCalls = new Map();
254
272
  let turnUsage;
255
273
  let assistantAppended = false;
256
274
  let toolEntries = Array.from(this.tools.values())
@@ -307,10 +325,28 @@ export class Agent {
307
325
  break;
308
326
  case "tool_call":
309
327
  if (chunk.isStart) {
310
- currentToolCall = { id: chunk.id, name: chunk.name, args: "" };
328
+ streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
329
+ yield { type: "tool_call_start", id: chunk.id, name: chunk.name };
311
330
  }
331
+ if (!streamingToolCalls.has(chunk.id)) {
332
+ streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
333
+ }
334
+ const currentToolCall = streamingToolCalls.get(chunk.id);
312
335
  if (currentToolCall) {
336
+ currentToolCall.name = chunk.name || currentToolCall.name;
313
337
  currentToolCall.args += chunk.arguments;
338
+ if (chunk.argumentsFull !== undefined) {
339
+ currentToolCall.args = chunk.argumentsFull;
340
+ }
341
+ if (chunk.arguments) {
342
+ yield {
343
+ type: "tool_call_delta",
344
+ id: currentToolCall.id,
345
+ name: currentToolCall.name,
346
+ argumentsDelta: chunk.arguments,
347
+ arguments: currentToolCall.args,
348
+ };
349
+ }
314
350
  }
315
351
  if (chunk.isEnd && currentToolCall) {
316
352
  assistantMsg.toolCalls.push({
@@ -318,11 +354,18 @@ export class Agent {
318
354
  name: currentToolCall.name,
319
355
  arguments: currentToolCall.args,
320
356
  });
321
- currentToolCall = null;
357
+ yield {
358
+ type: "tool_call_end",
359
+ id: currentToolCall.id,
360
+ name: currentToolCall.name,
361
+ arguments: currentToolCall.args,
362
+ };
363
+ streamingToolCalls.delete(chunk.id);
322
364
  }
323
365
  break;
324
366
  case "usage":
325
367
  turnUsage = chunk.usage;
368
+ this.budgetLedger?.recordUsage(chunk.usage, this.budgetSource);
326
369
  this.lastInputTokens = chunk.usage.promptTokens;
327
370
  this.lastAnchorMessageCount = this.messages.length;
328
371
  if (hookState.taskBudget) {
@@ -333,7 +376,10 @@ export class Agent {
333
376
  }
334
377
  break;
335
378
  }
379
+ for (const update of this.drainSubagentToolUpdates())
380
+ yield update;
336
381
  }
382
+ throwIfAborted(abortSignal);
337
383
  this.appendMessage(assistantMsg);
338
384
  assistantAppended = true;
339
385
  }
@@ -395,7 +441,41 @@ export class Agent {
395
441
  yield { type: "tool_start", id: tc.id, name: tc.name, args: tc.parsedArgs };
396
442
  const todosVersionBefore = this._todosVersion;
397
443
  const modeVersionBefore = this._modeVersion;
398
- let result = blockedResult ?? await this.executeTool(tc, cwd, abortSignal);
444
+ const updateQueue = createUpdateQueue();
445
+ let result;
446
+ if (blockedResult) {
447
+ result = blockedResult;
448
+ }
449
+ else {
450
+ const toolExecution = this.executeTool(tc, cwd, abortSignal, (update) => updateQueue.push(update));
451
+ let settled = false;
452
+ let resolved;
453
+ let rejected;
454
+ void toolExecution
455
+ .then((value) => {
456
+ resolved = value;
457
+ })
458
+ .catch((error) => {
459
+ rejected = error;
460
+ })
461
+ .finally(() => {
462
+ settled = true;
463
+ updateQueue.wake();
464
+ });
465
+ while (!settled || updateQueue.hasItems()) {
466
+ for (const update of updateQueue.drain()) {
467
+ yield { type: "tool_update", id: tc.id, name: tc.name, update };
468
+ }
469
+ for (const update of this.drainSubagentToolUpdates())
470
+ yield update;
471
+ if (!settled) {
472
+ await updateQueue.wait();
473
+ }
474
+ }
475
+ if (rejected)
476
+ throw rejected;
477
+ result = resolved ?? { content: `Error: Tool "${tc.name}" returned no result`, isError: true };
478
+ }
399
479
  throwIfAborted(abortSignal);
400
480
  await hookBus.runAfterToolCall({
401
481
  agent: this,
@@ -422,6 +502,8 @@ export class Agent {
422
502
  this.onToolResult?.(tc.name, result);
423
503
  executedResults.push(result);
424
504
  yield { type: "tool_end", id: tc.id, name: tc.name, result };
505
+ for (const update of this.drainSubagentToolUpdates())
506
+ yield update;
425
507
  if (this._todosVersion !== todosVersionBefore) {
426
508
  yield { type: "todos_updated", todos: this.getTodos() };
427
509
  }
@@ -466,6 +548,8 @@ export class Agent {
466
548
  }
467
549
  break;
468
550
  }
551
+ for (const update of this.drainSubagentToolUpdates())
552
+ yield update;
469
553
  yield { type: "agent_end" };
470
554
  }
471
555
  async recoverFromOverflow(attempt) {
@@ -507,62 +591,424 @@ export class Agent {
507
591
  }
508
592
  async runSubtask(input, cwd, options) {
509
593
  const subtaskType = options?.subtaskType;
510
- const policy = getSubtaskPolicy(subtaskType);
511
- const tools = filterToolsForSubtask([...this.tools.values()].filter((tool) => tool.name !== "task"), subtaskType);
594
+ const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
595
+ ?? builtinAgentProfiles().find((item) => item.subtaskType === "general_readonly");
596
+ const run = await this.runSubAgent(input, cwd, {
597
+ profile,
598
+ runId: randomUUID(),
599
+ subAgentId: randomUUID(),
600
+ parentToolCallId: "task",
601
+ description: options?.description,
602
+ });
603
+ const lines = [
604
+ "Note: task is deprecated. Use spawn_agent with a named profile instead.",
605
+ `Subtask type: ${profile.subtaskType ?? "general_readonly"}`,
606
+ ];
607
+ if (options?.description) {
608
+ lines.push(`Subtask description: ${options.description}`);
609
+ }
610
+ if (run.summary) {
611
+ lines.push("", "Subtask summary:", run.summary);
612
+ }
613
+ if (run.toolNotes.length > 0) {
614
+ lines.push("", "Subtask tools:", ...run.toolNotes.slice(0, 8).map((note) => `- ${note}`));
615
+ }
616
+ return {
617
+ content: lines.join("\n"),
618
+ status: getSubtaskPolicy(subtaskType).resultStatus,
619
+ isError: run.status !== "completed",
620
+ metadata: {
621
+ kind: "subagent",
622
+ reason: `Subtask (${profile.subtaskType ?? "general_readonly"}) investigation completed.`,
623
+ subagents: [run],
624
+ },
625
+ };
626
+ }
627
+ async runSubAgent(input, cwd, options) {
628
+ const record = this.createSubagentThreadRecord({
629
+ profile: options.profile,
630
+ task: typeof input === "string" ? input : "(multimodal task)",
631
+ runId: options.runId,
632
+ agentId: options.subAgentId,
633
+ parentToolCallId: options.parentToolCallId,
634
+ parentToolName: "subagent",
635
+ nickname: options.nickname,
636
+ });
637
+ await this.runSubagentThread(record, input, cwd, {
638
+ approval: options.approval ?? options.profile.approval,
639
+ abortSignal: options.abortSignal,
640
+ forkContext: options.forkContext,
641
+ directEmit: options.emitUpdate,
642
+ });
643
+ return subagentResultFromThread(record);
644
+ }
645
+ async spawnSubAgent(input, cwd, options) {
646
+ const record = this.createSubagentThreadRecord({
647
+ profile: options.profile,
648
+ task: typeof input === "string" ? input : "(multimodal task)",
649
+ parentToolCallId: options.parentToolCallId,
650
+ parentToolName: "spawn_agent",
651
+ });
652
+ this.subagentThreads.set(record.agentId, record);
653
+ this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
654
+ record.promise = this.runSubagentThread(record, input, cwd, {
655
+ approval: options.approval ?? record.profile.approval,
656
+ abortSignal: options.abortSignal,
657
+ forkContext: options.forkContext,
658
+ queueUpdates: true,
659
+ });
660
+ void record.promise.finally(() => this.notifySubagentWaiters(record));
661
+ return snapshotSubagentThread(record);
662
+ }
663
+ async waitSubAgents(options = {}) {
664
+ const targets = this.resolveSubagentTargets(options.agentIds);
665
+ if (targets.length === 0)
666
+ return [];
667
+ const completed = targets.filter((record) => isFinalSubagentStatus(record.status));
668
+ if (completed.length > 0)
669
+ return completed.map(snapshotSubagentThread);
670
+ const timeoutMs = normalizeWaitTimeout(options.timeoutMs);
671
+ let waiter;
672
+ await Promise.race([
673
+ new Promise((resolve) => {
674
+ waiter = resolve;
675
+ for (const record of targets) {
676
+ record.waiters.add(resolve);
677
+ }
678
+ }).finally(() => {
679
+ if (waiter) {
680
+ for (const record of targets) {
681
+ record.waiters.delete(waiter);
682
+ }
683
+ }
684
+ }),
685
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
686
+ ]);
687
+ const finished = targets.filter((record) => isFinalSubagentStatus(record.status));
688
+ return (finished.length > 0 ? finished : targets).map(snapshotSubagentThread);
689
+ }
690
+ async sendSubAgentInput(agentId, input, cwd, options = {}) {
691
+ const record = this.subagentThreads.get(agentId);
692
+ if (!record) {
693
+ throw new Error(`Unknown subagent: ${agentId}`);
694
+ }
695
+ if (record.status === "running" || record.status === "queued") {
696
+ if (!options.interrupt) {
697
+ throw new Error(`Subagent ${agentId} is still running. Call wait_agent first or pass interrupt:true.`);
698
+ }
699
+ record.abortController.abort(new AgentAbortError(`Subagent ${agentId} interrupted.`));
700
+ await record.promise?.catch(() => undefined);
701
+ record.abortController = new AbortController();
702
+ }
703
+ if (record.status === "closed") {
704
+ throw new Error(`Subagent ${agentId} is closed.`);
705
+ }
706
+ record.parentToolCallId = options.parentToolCallId ?? record.parentToolCallId;
707
+ record.parentToolName = "send_input";
708
+ record.task = typeof input === "string" ? input : "(multimodal task)";
709
+ record.summary = "";
710
+ record.toolNotes = [];
711
+ record.usage = undefined;
712
+ record.error = undefined;
713
+ record.updatedAt = Date.now();
714
+ record.promise = this.runSubagentThread(record, input, cwd, {
715
+ approval: record.profile.approval,
716
+ abortSignal: options.abortSignal,
717
+ queueUpdates: true,
718
+ reuseAgent: true,
719
+ });
720
+ void record.promise.finally(() => this.notifySubagentWaiters(record));
721
+ return snapshotSubagentThread(record);
722
+ }
723
+ async closeSubAgent(agentId) {
724
+ const record = this.subagentThreads.get(agentId);
725
+ if (!record) {
726
+ throw new Error(`Unknown subagent: ${agentId}`);
727
+ }
728
+ if (!isFinalSubagentStatus(record.status)) {
729
+ record.abortController.abort(new AgentAbortError(`Subagent ${agentId} closed.`));
730
+ await record.promise?.catch(() => undefined);
731
+ }
732
+ record.status = "closed";
733
+ record.updatedAt = Date.now();
734
+ this.queueSubagentUpdate(record, "cancelled", undefined, `${record.nickname} closed`);
735
+ this.notifySubagentWaiters(record);
736
+ return snapshotSubagentThread(record);
737
+ }
738
+ listSubAgents() {
739
+ return [...this.subagentThreads.values()].map(snapshotSubagentThread);
740
+ }
741
+ createSubagentThreadRecord(options) {
742
+ const now = Date.now();
743
+ const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
744
+ return {
745
+ agentId: options.agentId ?? randomUUID(),
746
+ runId: options.runId ?? randomUUID(),
747
+ nickname,
748
+ profile: options.profile,
749
+ parentToolCallId: options.parentToolCallId,
750
+ parentToolName: options.parentToolName,
751
+ status: "queued",
752
+ task: options.task,
753
+ summary: "",
754
+ toolNotes: [],
755
+ createdAt: now,
756
+ updatedAt: now,
757
+ abortController: new AbortController(),
758
+ waiters: new Set(),
759
+ };
760
+ }
761
+ async runSubagentThread(record, input, cwd, options) {
762
+ const emit = (status, event, message) => {
763
+ const update = this.buildSubagentUpdate(record, status, event, message);
764
+ options.directEmit?.(update);
765
+ if (options.queueUpdates) {
766
+ this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
767
+ }
768
+ };
769
+ const allTools = [...this.tools.values()];
770
+ const diagnostics = validateAgentProfileTools(allTools, record.profile, options.approval);
771
+ const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
772
+ for (const diagnostic of diagnostics.filter((item) => item.severity === "warning")) {
773
+ record.toolNotes.push(`profile: ${diagnostic.message}`);
774
+ }
775
+ if (blockingDiagnostics.length > 0) {
776
+ record.status = "blocked";
777
+ record.error = blockingDiagnostics.map((diagnostic) => diagnostic.message).join("\n");
778
+ record.updatedAt = Date.now();
779
+ emit("blocked", undefined, record.error);
780
+ this.notifySubagentWaiters(record);
781
+ return;
782
+ }
783
+ const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
784
+ const subAgent = options.reuseAgent && record.agent
785
+ ? record.agent
786
+ : this.createSubAgentInstance(record, tools, cwd, options.forkContext);
787
+ record.agent = subAgent;
788
+ record.status = "running";
789
+ record.updatedAt = Date.now();
790
+ emit("running", undefined, `Running ${record.nickname} (${record.profile.name})...`);
791
+ let turnSummaryBuffer = "";
792
+ let turnHadToolCall = false;
793
+ let executedAnyTool = false;
794
+ try {
795
+ const childAbortSignal = composeAbortSignals([
796
+ options.abortSignal,
797
+ record.abortController.signal,
798
+ ]);
799
+ for await (const event of subAgent.run(input, cwd, { abortSignal: childAbortSignal })) {
800
+ if (event.type === "text_delta") {
801
+ turnSummaryBuffer += event.content;
802
+ }
803
+ if (event.type === "tool_call_start"
804
+ || event.type === "tool_call_delta"
805
+ || event.type === "tool_call_end"
806
+ || event.type === "tool_start") {
807
+ turnHadToolCall = true;
808
+ }
809
+ if (event.type === "tool_end") {
810
+ executedAnyTool = true;
811
+ record.toolNotes.push(`${event.name}: ${summarizeSubagentToolEnd(event)}`);
812
+ }
813
+ if (event.type === "turn_end" && event.usage) {
814
+ record.usage = mergeUsage(record.usage, event.usage);
815
+ }
816
+ if (event.type === "turn_end") {
817
+ const turnSummary = stripProviderProtocolArtifacts(turnSummaryBuffer).trim();
818
+ if (!turnHadToolCall && turnSummary) {
819
+ // Only the latest tool-free assistant turn is a candidate for the summary;
820
+ // earlier ones are intermediate "I'll do X next" reasoning, not the final answer.
821
+ record.summary = turnSummary;
822
+ }
823
+ turnSummaryBuffer = "";
824
+ turnHadToolCall = false;
825
+ }
826
+ record.updatedAt = Date.now();
827
+ emit("running", event);
828
+ }
829
+ }
830
+ catch (error) {
831
+ const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
832
+ record.status = cancelled ? "cancelled" : "failed";
833
+ record.summary = sanitizeSubagentSummary(record.summary);
834
+ record.error = error?.message || String(error);
835
+ record.updatedAt = Date.now();
836
+ emit(record.status, undefined, record.error);
837
+ this.notifySubagentWaiters(record);
838
+ return;
839
+ }
840
+ record.summary = sanitizeSubagentSummary(record.summary);
841
+ if (needsExplicitFinalSummary(record, executedAnyTool)) {
842
+ await this.runSubagentFinalSummaryTurn(record, subAgent, cwd, options.abortSignal, emit);
843
+ }
844
+ record.status = "completed";
845
+ record.summary = sanitizeSubagentSummary(record.summary);
846
+ record.updatedAt = Date.now();
847
+ emit("completed", undefined, record.summary || `${record.nickname} completed`);
848
+ this.notifySubagentWaiters(record);
849
+ }
850
+ async runSubagentFinalSummaryTurn(record, subAgent, cwd, abortSignal, emit) {
851
+ const prompt = [
852
+ "Produce the final subagent summary now.",
853
+ "Do not call tools. Do not announce next steps or plans.",
854
+ "Use the evidence already gathered in this child thread.",
855
+ "Return concise findings with concrete file paths and explicit uncertainty.",
856
+ "Your entire response will be returned to the parent as the subagent's answer.",
857
+ ].join("\n");
858
+ subAgent.injectSystemReminder([
859
+ "Subagent final-summary mode is active.",
860
+ "Do not call tools. Do not announce next steps.",
861
+ "Use only the evidence already gathered in this child thread.",
862
+ "Return the final concise summary as your complete response.",
863
+ ].join("\n"));
864
+ let finalBuffer = "";
865
+ let finalHadToolCall = false;
866
+ const finalAbortSignal = composeAbortSignals([abortSignal, record.abortController.signal]);
867
+ for await (const event of subAgent.run(prompt, cwd, { abortSignal: finalAbortSignal })) {
868
+ if (event.type === "text_delta") {
869
+ finalBuffer += event.content;
870
+ }
871
+ if (event.type === "tool_call_start"
872
+ || event.type === "tool_call_delta"
873
+ || event.type === "tool_call_end"
874
+ || event.type === "tool_start") {
875
+ finalHadToolCall = true;
876
+ }
877
+ if (event.type === "turn_end" && event.usage) {
878
+ record.usage = mergeUsage(record.usage, event.usage);
879
+ }
880
+ emit("running", event);
881
+ }
882
+ const finalSummary = sanitizeSubagentSummary(finalBuffer);
883
+ if (!finalHadToolCall && finalSummary) {
884
+ record.summary = finalSummary;
885
+ }
886
+ }
887
+ createSubAgentInstance(record, tools, cwd, forkContext) {
888
+ const childToolNames = tools.map((tool) => tool.name);
889
+ const childSystemPrompt = buildSystemPrompt({
890
+ agentName: "Bubble",
891
+ configuredProvider: this.providerId || "none",
892
+ configuredModel: this.model || "none",
893
+ configuredModelId: this.model || "none",
894
+ thinkingLevel: this.thinkingLevel,
895
+ mode: "plan",
896
+ workingDir: cwd,
897
+ tools: childToolNames,
898
+ skills: childToolNames.includes("skill") ? this.skillSummaries : undefined,
899
+ memoryPrompt: childToolNames.some((name) => name === "memory_search" || name === "memory_read_summary")
900
+ ? this.memoryPrompt
901
+ : undefined,
902
+ agentProfilePrompt: [
903
+ `You are subagent ${record.nickname}. Your agent profile is ${record.profile.name}.`,
904
+ record.profile.prompt,
905
+ ].filter(Boolean).join("\n\n"),
906
+ });
512
907
  const subAgent = new Agent({
513
908
  provider: this.provider,
514
909
  providerId: this.providerId,
515
- model: this.model,
910
+ model: record.profile.model && record.profile.model !== "inherit" ? record.profile.model : this.model,
516
911
  tools,
517
912
  temperature: this.temperature,
518
913
  thinkingLevel: this.thinkingLevel,
519
914
  mode: "plan",
520
- maxTurns: policy.maxTurns,
521
- taskBudget: policy.taskBudget,
522
- systemPrompt: this.messages.find((message) => message.role === "system")?.content,
915
+ maxTurns: record.profile.maxTurns,
916
+ budgetLedger: this.budgetLedger,
917
+ budgetSource: { runId: record.runId, subAgentId: record.agentId },
918
+ systemPrompt: childSystemPrompt,
523
919
  hooks: this.hookDefinitions,
524
920
  });
525
- subAgent.injectSystemReminder(`<system-reminder>\n${policy.reminder}\n</system-reminder>`);
526
- let summary = "";
527
- const toolNotes = [];
528
- for await (const event of subAgent.run(input, cwd)) {
529
- if (event.type === "text_delta") {
530
- summary += event.content;
531
- }
532
- if (event.type === "tool_end") {
533
- const detail = event.result.metadata?.reason
534
- || event.result.content.split("\n").find((line) => line.trim())?.trim()
535
- || "completed";
536
- toolNotes.push(`${event.name}: ${detail}`);
537
- }
538
- }
539
- const lines = [];
540
- const trimmedSummary = summary.trim();
541
- lines.push(`Subtask type: ${policy.type}`);
542
- if (options?.description) {
543
- lines.push(`Subtask description: ${options.description}`);
921
+ if (forkContext) {
922
+ subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
544
923
  }
545
- if (trimmedSummary) {
546
- lines.push("", "Subtask summary:", trimmedSummary);
547
- }
548
- if (toolNotes.length > 0) {
549
- lines.push("", "Subtask tools:");
550
- for (const note of toolNotes.slice(0, 8)) {
551
- lines.push(`- ${note}`);
924
+ return subAgent;
925
+ }
926
+ forkMessagesForSubagent(childSystemPrompt) {
927
+ const forked = this.messages
928
+ .filter((message) => {
929
+ if (message.role === "system" || message.role === "meta")
930
+ return false;
931
+ if (message.role === "assistant" && message.toolCalls?.some((call) => isSubagentLifecycleTool(call.name))) {
932
+ return false;
552
933
  }
553
- }
554
- if (lines.length === 0) {
555
- lines.push("Subtask summary:", "No conclusive findings were produced.");
556
- }
934
+ if (message.role === "tool" && message.metadata?.kind === "subagent") {
935
+ return false;
936
+ }
937
+ return true;
938
+ })
939
+ .slice(-20);
940
+ return [{ role: "system", content: childSystemPrompt }, ...forked];
941
+ }
942
+ buildSubagentUpdate(record, status, event, message) {
557
943
  return {
558
- content: lines.join("\n"),
559
- status: policy.resultStatus,
944
+ type: "subagent_update",
945
+ parentToolCallId: record.parentToolCallId,
946
+ runId: record.runId,
947
+ subAgentId: record.agentId,
948
+ agentName: record.profile.name,
949
+ nickname: record.nickname,
950
+ status,
951
+ childEvent: event,
952
+ summaryDelta: event?.type === "text_delta" ? event.content : undefined,
953
+ toolName: "name" in (event ?? {}) ? event.name : undefined,
954
+ toolCallId: "id" in (event ?? {}) ? event.id : undefined,
955
+ message,
560
956
  metadata: {
561
- kind: "security",
562
- reason: `Subtask (${policy.type}) investigation completed.`,
957
+ kind: "subagent",
958
+ runId: record.runId,
959
+ subagents: [{
960
+ subAgentId: record.agentId,
961
+ agentName: record.profile.name,
962
+ nickname: record.nickname,
963
+ status,
964
+ profileSource: record.profile.source,
965
+ task: record.task,
966
+ summary: record.summary,
967
+ toolNotes: record.toolNotes,
968
+ usage: record.usage,
969
+ error: record.error,
970
+ }],
563
971
  },
564
972
  };
565
973
  }
974
+ queueSubagentUpdate(record, status, event, message) {
975
+ this.pendingSubagentUpdates.push({
976
+ id: record.parentToolCallId,
977
+ name: record.parentToolName,
978
+ update: this.buildSubagentUpdate(record, status, event, message),
979
+ });
980
+ }
981
+ drainSubagentToolUpdates() {
982
+ return this.pendingSubagentUpdates.splice(0, this.pendingSubagentUpdates.length)
983
+ .map((pending) => ({
984
+ type: "tool_update",
985
+ id: pending.id,
986
+ name: pending.name,
987
+ update: pending.update,
988
+ }));
989
+ }
990
+ activeSubagentNicknames() {
991
+ return [...this.subagentThreads.values()]
992
+ .filter((record) => !isFinalSubagentStatus(record.status))
993
+ .map((record) => record.nickname);
994
+ }
995
+ resolveSubagentTargets(agentIds) {
996
+ if (!agentIds || agentIds.length === 0) {
997
+ return [...this.subagentThreads.values()].filter((record) => record.status !== "closed");
998
+ }
999
+ return agentIds.map((id) => {
1000
+ const record = this.subagentThreads.get(id);
1001
+ if (!record) {
1002
+ throw new Error(`Unknown subagent: ${id}`);
1003
+ }
1004
+ return record;
1005
+ });
1006
+ }
1007
+ notifySubagentWaiters(record) {
1008
+ for (const waiter of record.waiters) {
1009
+ waiter();
1010
+ }
1011
+ }
566
1012
  maybeCompactResidentHistory() {
567
1013
  if (this.messages.length === 0) {
568
1014
  return;
@@ -608,7 +1054,7 @@ export class Agent {
608
1054
  this.messages.push(message);
609
1055
  this.onMessageAppend?.(message);
610
1056
  }
611
- async executeTool(toolCall, cwd, abortSignal) {
1057
+ async executeTool(toolCall, cwd, abortSignal, emitUpdate) {
612
1058
  throwIfAborted(abortSignal);
613
1059
  if (toolCall.name === "exit_plan_mode" && this._mode !== "plan") {
614
1060
  return {
@@ -626,7 +1072,7 @@ export class Agent {
626
1072
  if (this._mode === "plan" && !tool.readOnly) {
627
1073
  return {
628
1074
  content: `Error: Tool "${toolCall.name}" is not allowed in plan mode. ` +
629
- `In plan mode you may only use read-only tools (read, glob, grep, lsp, web_search, web_fetch, task, skill, todo_write, tool_search, question, exit_plan_mode). ` +
1075
+ `In plan mode you may only use read-only tools (read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill, todo_write, tool_search, question, exit_plan_mode). ` +
630
1076
  `To modify files or run commands, present your proposal and call exit_plan_mode so the user can review and approve it.`,
631
1077
  isError: true,
632
1078
  };
@@ -645,6 +1091,7 @@ export class Agent {
645
1091
  abortSignal,
646
1092
  toolCall: { id: toolCall.id, name: toolCall.name },
647
1093
  agent: this,
1094
+ emitUpdate,
648
1095
  });
649
1096
  }
650
1097
  catch (err) {
@@ -660,6 +1107,7 @@ function estimateResidentChars(messages) {
660
1107
  for (const message of messages) {
661
1108
  switch (message.role) {
662
1109
  case "system":
1110
+ case "meta":
663
1111
  total += message.content.length;
664
1112
  break;
665
1113
  case "tool":
@@ -694,6 +1142,34 @@ function throwIfAborted(signal) {
694
1142
  throw reason;
695
1143
  throw new AgentAbortError(typeof reason === "string" ? reason : undefined);
696
1144
  }
1145
+ function createUpdateQueue() {
1146
+ const items = [];
1147
+ let waiter;
1148
+ return {
1149
+ push(item) {
1150
+ items.push(item);
1151
+ this.wake();
1152
+ },
1153
+ drain() {
1154
+ return items.splice(0, items.length);
1155
+ },
1156
+ hasItems() {
1157
+ return items.length > 0;
1158
+ },
1159
+ wait() {
1160
+ if (items.length > 0)
1161
+ return Promise.resolve();
1162
+ return new Promise((resolve) => {
1163
+ waiter = resolve;
1164
+ });
1165
+ },
1166
+ wake() {
1167
+ const resolve = waiter;
1168
+ waiter = undefined;
1169
+ resolve?.();
1170
+ },
1171
+ };
1172
+ }
697
1173
  function estimateToolPayloadChars(messages) {
698
1174
  return messages.reduce((sum, message) => {
699
1175
  if (message.role !== "tool") {
@@ -703,7 +1179,7 @@ function estimateToolPayloadChars(messages) {
703
1179
  }, 0);
704
1180
  }
705
1181
  function countUserTurns(messages) {
706
- return messages.reduce((count, message) => count + (message.role === "user" && !message.isMeta ? 1 : 0), 0);
1182
+ return messages.reduce((count, message) => count + (message.role === "user" ? 1 : 0), 0);
707
1183
  }
708
1184
  function getCurrentHeapUsed() {
709
1185
  try {
@@ -713,3 +1189,76 @@ function getCurrentHeapUsed() {
713
1189
  return 0;
714
1190
  }
715
1191
  }
1192
+ function isFinalSubagentStatus(status) {
1193
+ return status === "completed"
1194
+ || status === "failed"
1195
+ || status === "blocked"
1196
+ || status === "cancelled"
1197
+ || status === "closed";
1198
+ }
1199
+ function normalizeWaitTimeout(value) {
1200
+ if (typeof value !== "number" || !Number.isFinite(value))
1201
+ return 30_000;
1202
+ return Math.max(100, Math.min(3_600_000, Math.floor(value)));
1203
+ }
1204
+ function isSubagentLifecycleTool(name) {
1205
+ return name === "subagent"
1206
+ || name === "spawn_agent"
1207
+ || name === "wait_agent"
1208
+ || name === "send_input"
1209
+ || name === "close_agent";
1210
+ }
1211
+ function sanitizeSubagentSummary(value) {
1212
+ return stripProviderProtocolArtifacts(value).trim();
1213
+ }
1214
+ function needsExplicitFinalSummary(record, executedAnyTool) {
1215
+ // If the subagent actually invoked any tool, always solicit an explicit final
1216
+ // summary. We cannot tell from the stream alone whether a tool-free trailing
1217
+ // turn was the real answer or mid-thought narration ("Let me try X next:").
1218
+ // Asking the model to restate its findings is cheap and yields predictable,
1219
+ // clean output. (Profile-validation notes in `toolNotes` do not count as
1220
+ // actual tool executions.)
1221
+ if (executedAnyTool)
1222
+ return true;
1223
+ if (!record.summary)
1224
+ return false;
1225
+ if (isOnlyProviderProtocolArtifacts(record.summary))
1226
+ return true;
1227
+ if (/<\/?[||][^<>]*>/.test(record.summary))
1228
+ return true;
1229
+ return false;
1230
+ }
1231
+ function summarizeSubagentToolEnd(event) {
1232
+ const metadata = (event.result.metadata ?? {});
1233
+ const reason = readString(metadata.reason);
1234
+ if (reason)
1235
+ return reason;
1236
+ const summary = readString(metadata.summary);
1237
+ if (summary)
1238
+ return summary;
1239
+ if (event.result.isError) {
1240
+ const firstLine = event.result.content.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
1241
+ return firstLine ? truncateForNote(firstLine) : "failed";
1242
+ }
1243
+ const matches = readNumber(metadata.matches);
1244
+ const pattern = readString(metadata.pattern);
1245
+ const path = readString(metadata.path);
1246
+ if (matches !== undefined) {
1247
+ const target = pattern ? ` for ${pattern}` : "";
1248
+ const within = path ? ` in ${path}` : "";
1249
+ return `${matches} match${matches === 1 ? "" : "es"}${target}${within}`;
1250
+ }
1251
+ const kind = readString(metadata.kind);
1252
+ if (path)
1253
+ return kind ? `${kind} ${path}` : path;
1254
+ return event.result.status ?? "completed";
1255
+ }
1256
+ function readString(value) {
1257
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1258
+ }
1259
+ function readNumber(value) {
1260
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1261
+ }
1262
+ function truncateForNote(value, max = 200) {
1263
+ return value.length <= max ? value : `${value.slice(0, max - 3)}...`;
1264
+ }