@bubblebrain-ai/bubble 0.0.38 → 0.0.40

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.
@@ -169,6 +169,8 @@ const INTERNAL_REASONING_REFERENCE_PATTERNS = [
169
169
  /Do not describe a subagent as running or still working/i,
170
170
  /Background task truth/i,
171
171
  /Never re-run work a finished task already did/i,
172
+ /Large-change checkpoint/i,
173
+ /delegation only adds merge risk/i,
172
174
  ];
173
175
  function consumeInternalBlockAtStart(text, final) {
174
176
  if (text.startsWith(INTERNAL_TAG_PREFIX)) {
@@ -56,6 +56,12 @@ export interface AgentProfileDiagnostic {
56
56
  export type AgentProfileScope = "user" | "project" | "both";
57
57
  export declare function discoverAgentProfiles(cwd: string, scope?: AgentProfileScope): DiscoverAgentProfilesResult;
58
58
  export declare function builtinAgentProfiles(): AgentProfile[];
59
+ /**
60
+ * The delegation nudge only fires when a write-capable spawn surface exists
61
+ * (large-task-delegation design §3): defense in depth for hosts that strip
62
+ * or override the builtin profiles.
63
+ */
64
+ export declare function hasWriteWorktreeProfile(profiles: AgentProfile[]): boolean;
59
65
  export declare function findAgentProfile(profiles: AgentProfile[], name: string): AgentProfile | undefined;
60
66
  export declare function assignAgentNickname(profile: AgentProfile, activeNicknames?: Iterable<string>): string;
61
67
  /**
@@ -134,8 +134,40 @@ export function builtinAgentProfiles() {
134
134
  toProfile("security_investigation"),
135
135
  toProfile("evidence_correlation"),
136
136
  toProfile("general_readonly"),
137
+ // The write-capable builtin (large-task-delegation design §3): before
138
+ // this, write_worktree was reachable only via user-authored profile
139
+ // frontmatter, so the delegation nudge would have recommended a surface
140
+ // that does not exist in a stock session.
141
+ {
142
+ name: "implementer",
143
+ description: "Write-capable implementation subagent (isolated git worktree)",
144
+ source: "builtin",
145
+ mode: "write_worktree",
146
+ model: "inherit",
147
+ tools: {
148
+ preset: "explicit",
149
+ include: ["read", "glob", "grep", "edit", "write", "bash", "lsp", "todo_write"],
150
+ exclude: [],
151
+ },
152
+ approval: "fail",
153
+ nicknameCandidates: DEFAULT_NICKNAME_CANDIDATES,
154
+ prompt: [
155
+ "You are an implementer subagent working in your own isolated git worktree — the parent tree is never touched.",
156
+ "Your worktree forks from the last commit; changes the parent made after that commit are NOT present unless included in your briefing.",
157
+ "Make the smallest coherent edits that complete your assigned group. Stay strictly inside the file set you were given; if a needed change falls outside it, report that instead of editing.",
158
+ "Verify your changes narrowly (targeted tests or typecheck) when possible, and end with a concise summary of files changed and verification results — the parent reviews and applies your worktree.",
159
+ ].join("\n"),
160
+ },
137
161
  ];
138
162
  }
163
+ /**
164
+ * The delegation nudge only fires when a write-capable spawn surface exists
165
+ * (large-task-delegation design §3): defense in depth for hosts that strip
166
+ * or override the builtin profiles.
167
+ */
168
+ export function hasWriteWorktreeProfile(profiles) {
169
+ return profiles.some((profile) => profile.mode === "write_worktree");
170
+ }
139
171
  export function findAgentProfile(profiles, name) {
140
172
  return profiles.find((profile) => profile.name === name)
141
173
  ?? profiles.find((profile) => profile.name === `builtin:${name}`);
package/dist/agent.d.ts CHANGED
@@ -112,6 +112,14 @@ export declare class Agent {
112
112
  outputTail: (id: string) => string | undefined;
113
113
  };
114
114
  private lastTaskReminderVersion;
115
+ /**
116
+ * Once-per-session latch for the large-task delegation nudge
117
+ * (large-task-delegation design §2): goal-loop continuations rebuild
118
+ * TurnHookState every run, so a per-turn latch would nag on every turn.
119
+ * If the model read the nudge once and chose not to delegate, repeating
120
+ * it is noise.
121
+ */
122
+ largeTaskNudgeConsumed: boolean;
115
123
  private _providerId;
116
124
  private _model;
117
125
  private tools;
@@ -295,6 +303,15 @@ export declare class Agent {
295
303
  abortSignal?: AbortSignal;
296
304
  }): Promise<SubagentThreadSnapshot>;
297
305
  closeSubAgent(agentId: string): Promise<SubagentThreadSnapshot>;
306
+ /**
307
+ * Live (non-final) children only — the delegation-nudge gate
308
+ * (large-task-delegation design §2). listSubAgents() is a grows-only
309
+ * session history (finished + resumed children stay forever), so it must
310
+ * never be used to answer "am I currently delegating?".
311
+ */
312
+ activeSubAgentCount(): number;
313
+ /** run_workflow children are workflowInternal and invisible to listSubAgents. */
314
+ hasRunningWorkflow(): boolean;
298
315
  listSubAgents(): SubagentThreadSnapshot[];
299
316
  /**
300
317
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
package/dist/agent.js CHANGED
@@ -20,6 +20,7 @@ import { HookBus } from "./orchestrator/hooks.js";
20
20
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
21
21
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
22
22
  import { buildTaskLifecycleReminder } from "./agent/task-lifecycle-reminder.js";
23
+ import { classifyTask } from "./agent/task-classifier.js";
23
24
  import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
24
25
  import { DEFAULT_AGENT_ROUTING, nearModelMatches, sanitizeAgentRouting, tierContextFromSnapshot } from "./agent/routing-catalog.js";
25
26
  import { buildModelRoutingPrompt } from "./prompt/routing.js";
@@ -108,6 +109,14 @@ export class Agent {
108
109
  */
109
110
  backgroundTasks;
110
111
  lastTaskReminderVersion = -1;
112
+ /**
113
+ * Once-per-session latch for the large-task delegation nudge
114
+ * (large-task-delegation design §2): goal-loop continuations rebuild
115
+ * TurnHookState every run, so a per-turn latch would nag on every turn.
116
+ * If the model read the nudge once and chose not to delegate, repeating
117
+ * it is noise.
118
+ */
119
+ largeTaskNudgeConsumed = false;
111
120
  _providerId;
112
121
  _model;
113
122
  tools = new Map();
@@ -548,6 +557,15 @@ export class Agent {
548
557
  events.push(...hook.events);
549
558
  this.injectHookModelContext(hook.result);
550
559
  this.appendMessage({ role: "user", content: input.content });
560
+ // Large-task detector (large-task-delegation design §1): a steer
561
+ // redirects the task — stale breadth evidence must not fire a nudge
562
+ // about the pre-steer task, and the classifier should reason about
563
+ // the redirected one.
564
+ hookState.exploredFiles?.clear();
565
+ hookState.largeTaskCheckpointDone = false;
566
+ if (typeof input.content === "string" && input.content.trim()) {
567
+ hookState.taskType = classifyTask(input.content);
568
+ }
551
569
  events.push({
552
570
  type: "input_applied",
553
571
  id: input.id,
@@ -1675,6 +1693,19 @@ export class Agent {
1675
1693
  this.subagentStore.notifyWaiters(record);
1676
1694
  return this.snapshotSubagent(record);
1677
1695
  }
1696
+ /**
1697
+ * Live (non-final) children only — the delegation-nudge gate
1698
+ * (large-task-delegation design §2). listSubAgents() is a grows-only
1699
+ * session history (finished + resumed children stay forever), so it must
1700
+ * never be used to answer "am I currently delegating?".
1701
+ */
1702
+ activeSubAgentCount() {
1703
+ return this.subagentStore.activeCount();
1704
+ }
1705
+ /** run_workflow children are workflowInternal and invisible to listSubAgents. */
1706
+ hasRunningWorkflow() {
1707
+ return [...this.workflowRuns.values()].some((record) => record.status === "running");
1708
+ }
1678
1709
  listSubAgents() {
1679
1710
  return this.subagentStore.values()
1680
1711
  .filter((record) => !record.workflowInternal)
package/dist/cli.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface CliArgs {
11
11
  resume?: boolean;
12
12
  sessionName?: string;
13
13
  print?: boolean;
14
+ /** Print-mode output: plain (default) or a single JSON object on stdout. */
15
+ outputFormat?: "plain" | "json";
14
16
  prompt?: string;
15
17
  thinkingLevel?: ThinkingLevel;
16
18
  mode?: PermissionMode;
package/dist/cli.js CHANGED
@@ -55,6 +55,15 @@ export function parseArgs(argv) {
55
55
  case "-p":
56
56
  args.print = true;
57
57
  break;
58
+ case "--output-format": {
59
+ const value = argv[++i];
60
+ if (value !== "plain" && value !== "json") {
61
+ console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain or json.`);
62
+ process.exit(1);
63
+ }
64
+ args.outputFormat = value;
65
+ break;
66
+ }
58
67
  case "--plan":
59
68
  args.mode = "plan";
60
69
  break;
@@ -108,6 +117,8 @@ Options (default):
108
117
  --dangerously-skip-permissions
109
118
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
110
119
  -p, --print Non-interactive mode (single prompt)
120
+ --output-format <fmt> Print-mode output: plain (default) or json
121
+ (one JSON object on stdout: text, usage, num_turns)
111
122
  -v, --version Print the installed version and exit
112
123
  -h, --help Show this help
113
124
 
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { SkillRegistry } from "./skills/registry.js";
20
20
  import { buildToolPromptOptions, createAllTools } from "./tools/index.js";
21
21
  import { getProcessManager } from "./tasks/manager.js";
22
22
  import { PromotionChannel } from "./tasks/promotion.js";
23
+ import { PrintRunCollector, formatPrintJson, formatPrintJsonError } from "./print-output.js";
23
24
  import { FileStateTracker } from "./tools/file-state.js";
24
25
  import { GoalStore } from "./goal/store.js";
25
26
  import { PermissionAwareApprovalController } from "./approval/controller.js";
@@ -552,7 +553,8 @@ async function main() {
552
553
  agent.injectModeReminder();
553
554
  }
554
555
  agent.injectDeferredToolsReminder();
555
- console.log(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
556
+ // JSON print mode reserves stdout for the single result object.
557
+ (args.outputFormat === "json" ? console.error : console.log)(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
556
558
  }
557
559
  }
558
560
  try {
@@ -568,33 +570,70 @@ async function main() {
568
570
  console.error(chalk.red("Error: No prompt provided."));
569
571
  process.exit(1);
570
572
  }
571
- let printedTurnText = false;
572
- for await (const event of agent.run(prompt, args.cwd)) {
573
- traceEvent("print_agent_event", summarizeAgentEventForTrace(event));
574
- if (event.type === "turn_start") {
575
- printedTurnText = false;
576
- }
577
- else if (event.type === "provider_retry") {
578
- // The stream died mid-response and the agent re-issues the whole
579
- // request. Text already on stdout cannot be un-printed, so at least
580
- // separate the retried response and say what happened.
581
- if (printedTurnText)
582
- process.stdout.write("\n");
583
- console.error(chalk.yellow(`[Stream interrupted; retrying (${event.attempt}/${event.maxAttempts}) — the partial text above is superseded by the retried response]`));
584
- }
585
- else if (event.type === "text_delta") {
586
- printedTurnText = true;
587
- process.stdout.write(event.content);
573
+ // JSON mode (benchmark/CI adapters): stdout carries exactly ONE JSON
574
+ // object; streaming text and tool logs move to stderr-silence. Plain
575
+ // mode is byte-for-byte the previous behavior.
576
+ const jsonOutput = args.outputFormat === "json";
577
+ const collector = new PrintRunCollector();
578
+ const printSessionId = sessionManager
579
+ ? basename(sessionManager.getSessionFile())
580
+ : undefined;
581
+ try {
582
+ let printedTurnText = false;
583
+ for await (const event of agent.run(prompt, args.cwd)) {
584
+ traceEvent("print_agent_event", summarizeAgentEventForTrace(event));
585
+ collector.onEvent(event);
586
+ if (jsonOutput) {
587
+ if (event.type === "provider_retry") {
588
+ console.error(chalk.yellow(`[Stream interrupted; retrying (${event.attempt}/${event.maxAttempts})]`));
589
+ }
590
+ continue;
591
+ }
592
+ if (event.type === "turn_start") {
593
+ printedTurnText = false;
594
+ }
595
+ else if (event.type === "provider_retry") {
596
+ // The stream died mid-response and the agent re-issues the whole
597
+ // request. Text already on stdout cannot be un-printed, so at least
598
+ // separate the retried response and say what happened.
599
+ if (printedTurnText)
600
+ process.stdout.write("\n");
601
+ console.error(chalk.yellow(`[Stream interrupted; retrying (${event.attempt}/${event.maxAttempts}) — the partial text above is superseded by the retried response]`));
602
+ }
603
+ else if (event.type === "text_delta") {
604
+ printedTurnText = true;
605
+ process.stdout.write(event.content);
606
+ }
607
+ else if (event.type === "tool_start") {
608
+ console.log(chalk.cyan(`\n[Tool: ${event.name}]`));
609
+ }
610
+ else if (event.type === "tool_end") {
611
+ const color = event.result.isError ? chalk.red : chalk.dim;
612
+ console.log(color(`[Result: ${event.result.content.slice(0, 200)}${event.result.content.length > 200 ? "..." : ""}]`));
613
+ }
588
614
  }
589
- else if (event.type === "tool_start") {
590
- console.log(chalk.cyan(`\n[Tool: ${event.name}]`));
615
+ if (jsonOutput) {
616
+ process.stdout.write(formatPrintJson({
617
+ summary: collector.summary(),
618
+ sessionId: printSessionId,
619
+ }) + "\n");
591
620
  }
592
- else if (event.type === "tool_end") {
593
- const color = event.result.isError ? chalk.red : chalk.dim;
594
- console.log(color(`[Result: ${event.result.content.slice(0, 200)}${event.result.content.length > 200 ? "..." : ""}]`));
621
+ else {
622
+ console.log();
595
623
  }
596
624
  }
597
- console.log();
625
+ catch (error) {
626
+ if (!jsonOutput)
627
+ throw error;
628
+ // Structured failure: adapters distinguish agent errors from crashes
629
+ // by the JSON error object + non-zero exit.
630
+ process.stdout.write(formatPrintJsonError({
631
+ message: error instanceof Error ? error.message : String(error),
632
+ summary: collector.summary(),
633
+ sessionId: printSessionId,
634
+ }) + "\n");
635
+ process.exitCode = 1;
636
+ }
598
637
  return;
599
638
  }
600
639
  const themeConfig = userConfig.getTheme();
@@ -795,7 +834,10 @@ async function readPipedStdin() {
795
834
  }
796
835
  main()
797
836
  .then(() => {
798
- void exitAfterFlush(0);
837
+ // Preserve an exit code set by a handled failure path (e.g. print-mode
838
+ // agent errors set exitCode = 1 and return normally) — exit(0) here
839
+ // would silently override it.
840
+ void exitAfterFlush(typeof process.exitCode === "number" ? process.exitCode : 0);
799
841
  })
800
842
  .catch((err) => {
801
843
  console.error(chalk.red(`Fatal error: ${err.message}`));
@@ -5,8 +5,11 @@ import { ExecutionGovernor } from "../agent/execution-governor.js";
5
5
  import { DiscoveryBarrier } from "../agent/discovery-barrier.js";
6
6
  import { arbitrateToolCall } from "../agent/tool-arbiter.js";
7
7
  import { buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
8
- import { orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
8
+ import { largeImplementationTaskReminder, orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
9
9
  import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
10
+ import { resolve as resolvePath } from "node:path";
11
+ import { traceEvent } from "../debug-trace.js";
12
+ import { builtinAgentProfiles, discoverAgentProfiles, hasWriteWorktreeProfile } from "../agent/profiles.js";
10
13
  import { buildSubagentLifecycleReminder } from "../agent/subagent-lifecycle-reminder.js";
11
14
  export function createDefaultHooks() {
12
15
  return [
@@ -33,6 +36,9 @@ export function createDefaultHooks() {
33
36
  const orchestrationReminder = orchestrationRequestReminder(ctx.input, ctx.agent.hasToolAvailable("run_workflow"));
34
37
  if (orchestrationReminder) {
35
38
  ctx.queueReminder(orchestrationReminder);
39
+ // The large-change checkpoint must not re-offer spawn_agent in a
40
+ // turn where this reminder just forbade it (design §3).
41
+ ctx.state.orchestrationReminderSent = true;
36
42
  }
37
43
  // User named a configured model ("gpt 5.6 sol"): resolve it against
38
44
  // the routable catalog and hand over the exact id at the decision
@@ -97,6 +103,12 @@ export function createDefaultHooks() {
97
103
  const toolCall = { ...arbitration.toolCall, ...(arbitration.note ? { arbiterNote: arbitration.note } : {}) };
98
104
  ctx.replaceToolCall(toolCall);
99
105
  ctx.state.governor?.beforeToolCall(toolCall);
106
+ // Large-change checkpoint (large-task-delegation design §1): the
107
+ // first mutation of the turn is the decision point — exploration is
108
+ // over, nothing has been edited yet.
109
+ if (isMutationTool(toolCall.name)) {
110
+ maybeQueueLargeTaskNudge(ctx);
111
+ }
100
112
  const blockedResult = ctx.state.discoveryBarrier?.beforeToolCall(toolCall);
101
113
  if (blockedResult)
102
114
  ctx.blockToolCall(blockedResult);
@@ -153,6 +165,18 @@ export function createDefaultHooks() {
153
165
  // dedup do the work.
154
166
  if (isCodeWriteResult(ctx.toolCall, ctx.result)) {
155
167
  markCodeChanged(ctx.state);
168
+ ctx.state.appliedEditCount = (ctx.state.appliedEditCount ?? 0) + 1;
169
+ }
170
+ // Large-task breadth evidence (design §1): only files actually READ.
171
+ // Search results contribute nothing — one glob returns up to 100
172
+ // match paths and would saturate any threshold instantly (review
173
+ // blocker). Bash-parsed reads (cat/head/sed -n) carry relative
174
+ // paths, so resolve against cwd before dedup.
175
+ if (!ctx.result.isError && ctx.result.metadata?.kind === "read") {
176
+ const path = typeof ctx.result.metadata.path === "string" ? ctx.result.metadata.path : undefined;
177
+ if (path) {
178
+ (ctx.state.exploredFiles ??= new Set()).add(resolvePath(ctx.cwd, path));
179
+ }
156
180
  }
157
181
  // Removed: active verification tracking. The previous design nagged the
158
182
  // model every turn until it ran a recognised verification command, and
@@ -206,6 +230,59 @@ export function createDefaultHooks() {
206
230
  function markCodeChanged(state) {
207
231
  state.codeChanged = true;
208
232
  }
233
+ // Large-task delegation checkpoint (large-task-delegation design §1-3).
234
+ const BREADTH_FILES = 8;
235
+ const BREADTH_FILES_WITH_PLAN = 5;
236
+ const BREADTH_TODOS = 6;
237
+ function maybeQueueLargeTaskNudge(ctx) {
238
+ // Evaluated exactly once per turn, at the first mutation — fired or not
239
+ // (mid-implementation re-detection is out of scope by design §4).
240
+ if (ctx.state.largeTaskCheckpointDone)
241
+ return;
242
+ ctx.state.largeTaskCheckpointDone = true;
243
+ const agent = ctx.agent;
244
+ const taskType = ctx.state.taskType;
245
+ const exploredFiles = ctx.state.exploredFiles?.size ?? 0;
246
+ const pendingTodos = agent.getTodos?.()
247
+ ?.filter((todo) => todo.status === "pending" || todo.status === "in_progress").length ?? 0;
248
+ const threshold = pendingTodos >= BREADTH_TODOS ? BREADTH_FILES_WITH_PLAN : BREADTH_FILES;
249
+ // Gate order: cheap checks first; the profile discovery (filesystem) runs
250
+ // only when everything else already passed.
251
+ const suppressedBy = agent.largeTaskNudgeConsumed ? "session_latch"
252
+ : (taskType === "code_review" || taskType === "security_investigation") ? "task_type"
253
+ : !agent.hasToolAvailable("spawn_agent") ? "no_spawn_tool"
254
+ : (agent.activeSubAgentCount?.() ?? 0) > 0 ? "active_children"
255
+ : agent.hasRunningWorkflow?.() ? "running_workflow"
256
+ : exploredFiles < threshold ? "below_threshold"
257
+ : !writeWorktreeSurfaceAvailable(ctx.cwd) ? "no_write_profile"
258
+ : undefined;
259
+ traceEvent("delegation_detector", {
260
+ fired: suppressedBy === undefined,
261
+ exploredFiles,
262
+ pendingTodos,
263
+ threshold,
264
+ taskType: taskType ?? "unknown",
265
+ ...(suppressedBy ? { suppressedBy } : {}),
266
+ });
267
+ if (suppressedBy !== undefined)
268
+ return;
269
+ agent.largeTaskNudgeConsumed = true;
270
+ ctx.queueReminder(largeImplementationTaskReminder({
271
+ exploredFiles,
272
+ pendingTodos,
273
+ appliedEdits: ctx.state.appliedEditCount ?? 0,
274
+ orchestrationRequested: ctx.state.orchestrationReminderSent === true,
275
+ }));
276
+ }
277
+ function writeWorktreeSurfaceAvailable(cwd) {
278
+ try {
279
+ const discovered = discoverAgentProfiles(cwd, "both").profiles;
280
+ return hasWriteWorktreeProfile([...builtinAgentProfiles(), ...discovered]);
281
+ }
282
+ catch {
283
+ return hasWriteWorktreeProfile(builtinAgentProfiles());
284
+ }
285
+ }
209
286
  function isCodeWriteResult(_toolCall, result) {
210
287
  if (result.isError || result.status === "blocked" || result.status === "cancelled" || result.status === "command_error") {
211
288
  return false;
@@ -26,6 +26,13 @@ export interface TurnHookState {
26
26
  total: number;
27
27
  spent: number;
28
28
  };
29
+ exploredFiles?: Set<string>;
30
+ /** Checkpoint evaluated at the first mutation — once per turn, fired or not. */
31
+ largeTaskCheckpointDone?: boolean;
32
+ /** Successful mutations this turn; rendered as K in the nudge wording. */
33
+ appliedEditCount?: number;
34
+ /** orchestrationRequestReminder fired: the nudge offers only run_workflow. */
35
+ orchestrationReminderSent?: boolean;
29
36
  }
30
37
  export interface TurnHookContext {
31
38
  agent: Agent;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Structured output for print mode (`-p --output-format json`).
3
+ *
4
+ * Built for benchmark/CI adapters (Pier, terminal-bench): stdout carries
5
+ * exactly one JSON object; all human-facing noise goes to stderr. Field
6
+ * names follow the grok-build/Claude Code convention (text, stopReason,
7
+ * sessionId, usage.{input_tokens,...}, num_turns) so existing harness
8
+ * adapters port with minimal changes.
9
+ */
10
+ import type { AgentEvent } from "./types.js";
11
+ export type PrintOutputFormat = "plain" | "json";
12
+ export declare function parseOutputFormat(raw: string | undefined): PrintOutputFormat | undefined;
13
+ export interface PrintUsageSummary {
14
+ input_tokens: number;
15
+ cache_read_input_tokens: number;
16
+ output_tokens: number;
17
+ reasoning_tokens: number;
18
+ total_tokens: number;
19
+ }
20
+ export interface PrintRunSummary {
21
+ /** Final answer: the last turn's streamed text. */
22
+ text: string;
23
+ num_turns: number;
24
+ num_tool_calls: number;
25
+ usage: PrintUsageSummary;
26
+ /**
27
+ * False when no turn carried provider usage (some endpoints — notably the
28
+ * Grok subscription proxy — omit it): zeros then mean "not reported",
29
+ * not "free". Mirrors the goal engine's markTokenUsageUnavailable and
30
+ * grok-build's usage_is_incomplete convention.
31
+ */
32
+ usage_reported: boolean;
33
+ }
34
+ export declare class PrintRunCollector {
35
+ private turnText;
36
+ private lastTurnText;
37
+ private turns;
38
+ private toolCalls;
39
+ private usageReported;
40
+ private readonly usage;
41
+ onEvent(event: AgentEvent): void;
42
+ private addUsage;
43
+ summary(): PrintRunSummary;
44
+ }
45
+ export declare function formatPrintJson(input: {
46
+ summary: PrintRunSummary;
47
+ sessionId?: string;
48
+ stopReason?: "end_turn" | "error" | "cancelled";
49
+ }): string;
50
+ export declare function formatPrintJsonError(input: {
51
+ message: string;
52
+ summary?: PrintRunSummary;
53
+ sessionId?: string;
54
+ }): string;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Structured output for print mode (`-p --output-format json`).
3
+ *
4
+ * Built for benchmark/CI adapters (Pier, terminal-bench): stdout carries
5
+ * exactly one JSON object; all human-facing noise goes to stderr. Field
6
+ * names follow the grok-build/Claude Code convention (text, stopReason,
7
+ * sessionId, usage.{input_tokens,...}, num_turns) so existing harness
8
+ * adapters port with minimal changes.
9
+ */
10
+ export function parseOutputFormat(raw) {
11
+ if (raw === undefined)
12
+ return undefined;
13
+ if (raw === "plain" || raw === "json")
14
+ return raw;
15
+ return undefined;
16
+ }
17
+ export class PrintRunCollector {
18
+ turnText = "";
19
+ lastTurnText = "";
20
+ turns = 0;
21
+ toolCalls = 0;
22
+ usageReported = false;
23
+ usage = {
24
+ input_tokens: 0,
25
+ cache_read_input_tokens: 0,
26
+ output_tokens: 0,
27
+ reasoning_tokens: 0,
28
+ total_tokens: 0,
29
+ };
30
+ onEvent(event) {
31
+ switch (event.type) {
32
+ case "turn_start":
33
+ this.turns += 1;
34
+ this.turnText = "";
35
+ break;
36
+ case "text_delta":
37
+ this.turnText += event.content;
38
+ break;
39
+ case "provider_retry":
40
+ // The retried response supersedes the partial text.
41
+ this.turnText = "";
42
+ break;
43
+ case "tool_start":
44
+ this.toolCalls += 1;
45
+ break;
46
+ case "turn_end":
47
+ // Usage reaches AgentEvent only on turn_end (the stream-level
48
+ // "usage" chunk is aggregated into it by the agent loop).
49
+ if (this.turnText.trim())
50
+ this.lastTurnText = this.turnText;
51
+ if (event.usage)
52
+ this.addUsage(event.usage);
53
+ break;
54
+ default:
55
+ break;
56
+ }
57
+ }
58
+ addUsage(usage) {
59
+ this.usageReported = true;
60
+ this.usage.input_tokens += usage.promptTokens ?? 0;
61
+ this.usage.cache_read_input_tokens += usage.promptCacheHitTokens ?? 0;
62
+ this.usage.output_tokens += usage.completionTokens ?? 0;
63
+ this.usage.reasoning_tokens += usage.reasoningTokens ?? 0;
64
+ this.usage.total_tokens += usage.totalTokens
65
+ ?? ((usage.promptTokens ?? 0) + (usage.completionTokens ?? 0));
66
+ }
67
+ summary() {
68
+ return {
69
+ text: (this.lastTurnText || this.turnText).trim(),
70
+ num_turns: this.turns,
71
+ num_tool_calls: this.toolCalls,
72
+ usage: { ...this.usage },
73
+ usage_reported: this.usageReported,
74
+ };
75
+ }
76
+ }
77
+ export function formatPrintJson(input) {
78
+ return JSON.stringify({
79
+ text: input.summary.text,
80
+ stopReason: input.stopReason ?? "end_turn",
81
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
82
+ num_turns: input.summary.num_turns,
83
+ num_tool_calls: input.summary.num_tool_calls,
84
+ usage: input.summary.usage,
85
+ usage_reported: input.summary.usage_reported,
86
+ });
87
+ }
88
+ export function formatPrintJsonError(input) {
89
+ return JSON.stringify({
90
+ type: "error",
91
+ message: input.message,
92
+ stopReason: "error",
93
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
94
+ ...(input.summary
95
+ ? {
96
+ text: input.summary.text,
97
+ num_turns: input.summary.num_turns,
98
+ num_tool_calls: input.summary.num_tool_calls,
99
+ usage: input.summary.usage,
100
+ }
101
+ : {}),
102
+ });
103
+ }
@@ -51,9 +51,12 @@ earlier work lives in an existing subagent, prefer send_input to resume it
51
51
  over spawning a fresh one.
52
52
 
53
53
  Do NOT delegate when:
54
- - The task requires editing files or running state-changing commands.
55
- Built-in subagents are read-only; do edits and writes yourself unless a
56
- write-capable (write_worktree) profile is explicitly available.
54
+ - The edits are ENTANGLED — shared state, one file feeding the next, order
55
+ matters. Write delegation exists (the builtin "implementer" profile runs
56
+ in an isolated git worktree), but it only pays off for INDEPENDENT edit
57
+ groups with disjoint file sets. Worktree children fork from the last
58
+ commit and the parent reviews/applies their changes afterward; entangled
59
+ work through that contract only adds merge risk — do it yourself.
57
60
  - The task takes one or two tool calls (reading a single file, looking up one
58
61
  definition). The handoff overhead costs more than the task.
59
62
  - Doing it well depends on conversation context (preferences the user stated
@@ -23,4 +23,21 @@ export declare function reminderForTaskType(taskType: TaskType, options?: TaskRe
23
23
  * this, 2026-07-12 live case). Silent on no match: zero standing noise.
24
24
  */
25
25
  export declare function userNamedModelReminder(input: string | import("../types.js").ContentPart[], routableModels: RoutableModelEntry[] | undefined, canSpawn: boolean): string | undefined;
26
+ /**
27
+ * Large-change checkpoint (large-task-delegation design §3): injected once
28
+ * per session at the first mutation of a broad turn. Carries the routing
29
+ * decision criteria (independent vs entangled) and the real worktree
30
+ * mechanics (children fork from the last commit; disjoint file sets) —
31
+ * never a command. The "smallest coherent edit" clause is lifted verbatim
32
+ * from the implementation-workflow reminder so the two compose.
33
+ */
34
+ export interface LargeTaskReminderInput {
35
+ exploredFiles: number;
36
+ pendingTodos: number;
37
+ /** Mutations already applied this turn (the current batch lands regardless). */
38
+ appliedEdits: number;
39
+ /** orchestrationRequestReminder fired this turn: only offer run_workflow. */
40
+ orchestrationRequested: boolean;
41
+ }
42
+ export declare function largeImplementationTaskReminder(input: LargeTaskReminderInput): string;
26
43
  export declare function orchestrationRequestReminder(input: string | import("../types.js").ContentPart[], canRunWorkflow: boolean): string | undefined;
@@ -119,6 +119,21 @@ export function userNamedModelReminder(input, routableModels, canSpawn) {
119
119
  "Use these exact ids in spawn_agent / run_workflow `model` params; do not retype model ids from memory.",
120
120
  ].join("\n");
121
121
  }
122
+ export function largeImplementationTaskReminder(input) {
123
+ const plan = input.pendingTodos > 0 ? ` (plan: ${input.pendingTodos} open todo items)` : "";
124
+ const applied = input.appliedEdits > 0
125
+ ? ` ${input.appliedEdits} edit${input.appliedEdits === 1 ? "" : "s"} from your current batch will land regardless.`
126
+ : "";
127
+ const mechanism = input.orchestrationRequested
128
+ ? "one run_workflow script (the user asked for a coordinated workflow — keep everything in it)"
129
+ : "write_worktree subagents (spawn_agent with the implementer profile) or one run_workflow script";
130
+ return wrapInSystemReminder(`
131
+ Large-change checkpoint: you have read ${input.exploredFiles} files this turn${plan} and are starting to edit.${applied}
132
+ - If the REMAINING edits form INDEPENDENT groups (per-module, per-file, same shape repeated), split them across ${mechanism}. Assign each child a DISJOINT file set; keep shared files (types, registries, exports) for yourself. Children fork from the last COMMIT — commit your applied edits first, or fold them into the child briefings. Each child still makes the smallest coherent edit for its group.
133
+ - If the edits are ENTANGLED (shared state, one file feeding the next, order matters), delegation only adds merge risk — proceed yourself.
134
+ - Work already small enough to finish directly: just finish it.
135
+ `);
136
+ }
122
137
  const ORCHESTRATION_REQUEST = /\b(?:workflows?|orchestrat\w*|agent[ -]?teams?|fan[ -]?out)\b|工作流|编排|(?:智能体|代理|agent)\s*(?:团队|小队)/i;
123
138
  export function orchestrationRequestReminder(input, canRunWorkflow) {
124
139
  if (!canRunWorkflow)
package/dist/provider.js CHANGED
@@ -15,6 +15,7 @@ import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
15
15
  import { resolveProviderRequestConfig } from "./provider-transform.js";
16
16
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
17
17
  import { RateLimitError } from "./network/errors.js";
18
+ import { ProviderStreamInterruptedError } from "./network/retry.js";
18
19
  // Diagnostic logger for tool-args byte-loss investigation. Activate with
19
20
  // BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
20
21
  // Each line is a JSON record describing a transition. When debugging is off,
@@ -188,13 +189,24 @@ export function createProviderInstance(options) {
188
189
  // surfaces a final 429 as a typed RateLimitError instead of a string.
189
190
  let stream;
190
191
  stream = (await createCompletion(body));
191
- yield* translateOpenAIStream(stream, {
192
- toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
193
- reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
194
- textMergeMode: resolveTextMergeMode(options.providerId || "", options.baseURL),
195
- debugProviderId: options.providerId || "",
196
- debugModelId: chatOptions.model,
197
- });
192
+ // A socket drop while iterating the SSE stream must surface as
193
+ // ProviderStreamInterruptedError so the agent loop re-issues the request;
194
+ // a raw network error here aborts the whole run (anthropic/codex/ai-sdk
195
+ // paths already wrap — this generic chat-completions path did not).
196
+ try {
197
+ yield* translateOpenAIStream(stream, {
198
+ toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
199
+ reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
200
+ textMergeMode: resolveTextMergeMode(options.providerId || "", options.baseURL),
201
+ debugProviderId: options.providerId || "",
202
+ debugModelId: chatOptions.model,
203
+ });
204
+ }
205
+ catch (error) {
206
+ if (chatOptions.abortSignal?.aborted)
207
+ throw error;
208
+ throw new ProviderStreamInterruptedError(`Provider stream interrupted: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
209
+ }
198
210
  yield { type: "done" };
199
211
  }
200
212
  async function complete(messages, chatOptions) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {