@bubblebrain-ai/bubble 0.0.39 → 0.0.41

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.
@@ -1,4 +1,12 @@
1
1
  import type { AssistantProviderMetadata } from "../types.js";
2
+ /** Prefix shared by every internal reminder/context block this module emits. */
3
+ export declare const INTERNAL_BLOCK_PREFIX = "<bubble_internal_";
4
+ /**
5
+ * True when the text is an internal reminder/context block (e.g. a meta
6
+ * message that the projector re-rolled into user role) rather than something
7
+ * the user actually typed.
8
+ */
9
+ export declare function isInternalBlockContent(text: string): boolean;
2
10
  export declare function formatInternalReminderBlock(kind: string, content: string): string;
3
11
  export declare function formatInternalContextBlock(kind: string, content: string): string;
4
12
  export declare function sanitizeInternalReminderBlocks(text: string): string;
@@ -27,6 +27,16 @@ const LEGACY_REMINDER_END_PHRASES = [
27
27
  "- Skip the \"investigate the codebase\" step that applies to larger changes.",
28
28
  "Do not put the final answer only in hidden reasoning.",
29
29
  ];
30
+ /** Prefix shared by every internal reminder/context block this module emits. */
31
+ export const INTERNAL_BLOCK_PREFIX = "<bubble_internal_";
32
+ /**
33
+ * True when the text is an internal reminder/context block (e.g. a meta
34
+ * message that the projector re-rolled into user role) rather than something
35
+ * the user actually typed.
36
+ */
37
+ export function isInternalBlockContent(text) {
38
+ return text.trimStart().startsWith(INTERNAL_BLOCK_PREFIX);
39
+ }
30
40
  export function formatInternalReminderBlock(kind, content) {
31
41
  return formatInternalBlock("reminder", kind, content);
32
42
  }
package/dist/agent.d.ts CHANGED
@@ -175,6 +175,7 @@ export declare class Agent {
175
175
  private readonly subagentUpdateWakers;
176
176
  private lastInputTokens;
177
177
  private lastAnchorMessageCount;
178
+ private compactionStats;
178
179
  constructor(options: AgentOptions);
179
180
  /**
180
181
  * Re-inject the deferred-tools advertisement. Hosts that reassign
@@ -256,6 +257,13 @@ export declare class Agent {
256
257
  private recoverFromOverflow;
257
258
  compactResidentHistory(): void;
258
259
  private maybeCompactWithLLM;
260
+ /** Snapshot of how often each compaction path rewrote history this run. */
261
+ getCompactionStats(): {
262
+ resident: number;
263
+ llm: number;
264
+ overflow: number;
265
+ droppedMessages: number;
266
+ };
259
267
  /**
260
268
  * Stream a 9-section handoff summary of `oldMessages` from the session model.
261
269
  * Powers the manual `/compact` command: streaming (rather than `complete()`)
package/dist/agent.js CHANGED
@@ -172,6 +172,12 @@ export class Agent {
172
172
  subagentUpdateWakers = new Set();
173
173
  lastInputTokens = null;
174
174
  lastAnchorMessageCount = null;
175
+ // How often each compaction path actually rewrote history this run.
176
+ // Surfaced via getCompactionStats() for print-mode telemetry: without it,
177
+ // "did the model ever stop seeing the original instruction?" is
178
+ // unanswerable from the outside (only counts real compactions, not
179
+ // byte-saving projection rewrites).
180
+ compactionStats = { resident: 0, llm: 0, overflow: 0, droppedMessages: 0 };
175
181
  constructor(options) {
176
182
  this.provider = options.provider;
177
183
  this.sessionID = options.sessionID;
@@ -992,6 +998,9 @@ export class Agent {
992
998
  }
993
999
  const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
994
1000
  consecutiveOverflowRecoveries += 1;
1001
+ this.compactionStats.overflow += 1;
1002
+ this.compactionStats.droppedMessages += droppedMessages;
1003
+ traceEvent("compaction_fired", { path: "overflow", droppedMessages });
995
1004
  yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
996
1005
  continue;
997
1006
  }
@@ -1507,10 +1516,16 @@ export class Agent {
1507
1516
  this.lastInputTokens = null;
1508
1517
  this.lastAnchorMessageCount = null;
1509
1518
  this.fileStateTracker?.invalidateReadHistory();
1519
+ this.compactionStats.llm += 1;
1520
+ traceEvent("compaction_fired", { path: "llm" });
1510
1521
  }
1511
1522
  // If LLM compaction failed for any reason, leave this.messages alone —
1512
1523
  // the projector's algorithmic budgeted-mode passes will still try.
1513
1524
  }
1525
+ /** Snapshot of how often each compaction path rewrote history this run. */
1526
+ getCompactionStats() {
1527
+ return { ...this.compactionStats };
1528
+ }
1514
1529
  /**
1515
1530
  * Stream a 9-section handoff summary of `oldMessages` from the session model.
1516
1531
  * Powers the manual `/compact` command: streaming (rather than `complete()`)
@@ -2537,6 +2552,8 @@ export class Agent {
2537
2552
  const compacted = compactMessages(candidate, { keepRecentTurns });
2538
2553
  if (compacted.compacted && compacted.messages) {
2539
2554
  candidate = compacted.messages;
2555
+ this.compactionStats.resident += 1;
2556
+ traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
2540
2557
  }
2541
2558
  }
2542
2559
  const afterChars = estimateResidentChars(candidate);
@@ -1,5 +1,20 @@
1
1
  import type { Message } from "../types.js";
2
2
  import type { SessionLogEntry } from "../session-types.js";
3
+ /**
4
+ * Compaction must never summarize away what the user actually asked for:
5
+ * requirements that appear late in a long instruction ("commit when done",
6
+ * "write the report in French") would otherwise vanish once the heuristic
7
+ * summary truncates the goal line. Messages larger than this cap are exempt
8
+ * from pinning (e.g. a pasted megabyte of logs as the opening message) —
9
+ * summarization is the lesser evil there.
10
+ */
11
+ export declare const PINNED_INSTRUCTION_MAX_CHARS = 8192;
12
+ /**
13
+ * Index of the first message the user actually wrote: user-role, not an
14
+ * internal reminder/context block re-rolled into user role by the projector,
15
+ * and small enough to keep verbatim.
16
+ */
17
+ export declare function findFirstRealUserIndex(messages: Message[]): number;
3
18
  export interface CompactOptions {
4
19
  keepRecentTurns?: number;
5
20
  maxSummaryItems?: number;
@@ -1,3 +1,43 @@
1
+ import { isInternalBlockContent } from "../agent/internal-reminder-sanitizer.js";
2
+ /**
3
+ * Compaction must never summarize away what the user actually asked for:
4
+ * requirements that appear late in a long instruction ("commit when done",
5
+ * "write the report in French") would otherwise vanish once the heuristic
6
+ * summary truncates the goal line. Messages larger than this cap are exempt
7
+ * from pinning (e.g. a pasted megabyte of logs as the opening message) —
8
+ * summarization is the lesser evil there.
9
+ */
10
+ export const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
+ function userMessageText(message) {
12
+ if (typeof message.content === "string")
13
+ return message.content;
14
+ if (Array.isArray(message.content)) {
15
+ return message.content
16
+ .filter((part) => part.type === "text")
17
+ .map((part) => part.text)
18
+ .join(" ");
19
+ }
20
+ return "";
21
+ }
22
+ /**
23
+ * Index of the first message the user actually wrote: user-role, not an
24
+ * internal reminder/context block re-rolled into user role by the projector,
25
+ * and small enough to keep verbatim.
26
+ */
27
+ export function findFirstRealUserIndex(messages) {
28
+ for (let index = 0; index < messages.length; index++) {
29
+ const message = messages[index];
30
+ if (message.role !== "user")
31
+ continue;
32
+ const text = userMessageText(message);
33
+ if (!text.trim() || isInternalBlockContent(text))
34
+ continue;
35
+ if (text.length > PINNED_INSTRUCTION_MAX_CHARS)
36
+ return -1;
37
+ return index;
38
+ }
39
+ return -1;
40
+ }
1
41
  export function planSessionCompaction(entries, options = {}) {
2
42
  const keepRecentTurns = options.keepRecentTurns ?? 2;
3
43
  const metadataEntries = entries.filter((entry) => entry.type === "metadata");
@@ -74,12 +114,20 @@ export function compactMessages(messages, options = {}) {
74
114
  }
75
115
  const oldMessages = conversationalMessages.slice(0, keepStartIndex);
76
116
  const keptMessages = conversationalMessages.slice(keepStartIndex);
77
- const summary = buildMessageSummary(oldMessages, maxSummaryItems);
117
+ // Pin the original instruction: it stays verbatim above the summary instead
118
+ // of being crushed into the summary's 140-char goal line.
119
+ const pinnedIndex = findFirstRealUserIndex(oldMessages);
120
+ const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
121
+ const summaryInput = pinnedIndex >= 0
122
+ ? oldMessages.filter((_, index) => index !== pinnedIndex)
123
+ : oldMessages;
124
+ const summary = buildMessageSummary(summaryInput, maxSummaryItems);
78
125
  if (!summary) {
79
126
  return { compacted: false };
80
127
  }
81
128
  const compactedMessages = [
82
129
  ...preservedContextMessages.map((message) => cloneMessage(message)),
130
+ ...(pinnedMessage ? [cloneMessage(pinnedMessage)] : []),
83
131
  {
84
132
  role: "system",
85
133
  content: `Previous conversation summary:\n${summary}`,
@@ -90,7 +138,7 @@ export function compactMessages(messages, options = {}) {
90
138
  compacted: true,
91
139
  summary,
92
140
  messages: compactedMessages,
93
- droppedEntries: oldMessages.length,
141
+ droppedEntries: summaryInput.length,
94
142
  };
95
143
  }
96
144
  export function compactCurrentTurnToolGroups(messages, options = {}) {
@@ -10,6 +10,7 @@
10
10
  // Failure modes are explicit: returns { compacted: false, reason } so the
11
11
  // caller can fall back to algorithmic compaction without an exception.
12
12
  import { estimateContextTokens } from "./budget.js";
13
+ import { findFirstRealUserIndex } from "./compact.js";
13
14
  export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
14
15
 
15
16
  Include:
@@ -40,9 +41,21 @@ export async function compactWithLLM(messages, options) {
40
41
  // priorTurns: everything from earlier user turns (multi-turn case)
41
42
  // lastUser: the user's current ask (always kept verbatim)
42
43
  // currentTurn: the assistant + tool groups produced in response so far
44
+ //
45
+ // The FIRST real user message (the original instruction) is pinned verbatim
46
+ // too: after meta reminders get re-rolled into user role by the projector,
47
+ // "last user message" often points at a reminder, not the instruction — and
48
+ // late requirements in a long instruction must survive compaction.
43
49
  const priorTurns = body.slice(0, lastUserIndex);
44
50
  const lastUser = body[lastUserIndex];
45
51
  const currentTurn = body.slice(lastUserIndex + 1);
52
+ const firstRealUserIndex = findFirstRealUserIndex(body);
53
+ const pinnedFirstUser = firstRealUserIndex >= 0 && firstRealUserIndex < lastUserIndex
54
+ ? body[firstRealUserIndex]
55
+ : undefined;
56
+ const summarizablePriorTurns = pinnedFirstUser
57
+ ? priorTurns.filter((message) => message !== pinnedFirstUser)
58
+ : priorTurns;
46
59
  const groups = [];
47
60
  let active = null;
48
61
  for (const msg of currentTurn) {
@@ -61,9 +74,10 @@ export async function compactWithLLM(messages, options) {
61
74
  const evictedGroups = groups.slice(0, groups.length - keptGroupCount);
62
75
  const keptGroups = groups.slice(groups.length - keptGroupCount);
63
76
  // What we'll send to the model to summarize: prior turns + the older groups
64
- // in the current turn (everything we're about to evict).
77
+ // in the current turn (everything we're about to evict). The pinned first
78
+ // instruction is excluded — it survives verbatim, not as summary fodder.
65
79
  const toSummarize = [
66
- ...priorTurns,
80
+ ...summarizablePriorTurns,
67
81
  ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
68
82
  ];
69
83
  if (toSummarize.length === 0) {
@@ -105,6 +119,7 @@ export async function compactWithLLM(messages, options) {
105
119
  }
106
120
  const compacted = [
107
121
  ...preserved.map(cloneMessage),
122
+ ...(pinnedFirstUser ? [cloneMessage(pinnedFirstUser)] : []),
108
123
  {
109
124
  role: "user",
110
125
  content: `${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`,
package/dist/main.js CHANGED
@@ -616,6 +616,7 @@ async function main() {
616
616
  process.stdout.write(formatPrintJson({
617
617
  summary: collector.summary(),
618
618
  sessionId: printSessionId,
619
+ compaction: agent.getCompactionStats(),
619
620
  }) + "\n");
620
621
  }
621
622
  else {
@@ -631,6 +632,7 @@ async function main() {
631
632
  message: error instanceof Error ? error.message : String(error),
632
633
  summary: collector.summary(),
633
634
  sessionId: printSessionId,
635
+ compaction: agent.getCompactionStats(),
634
636
  }) + "\n");
635
637
  process.exitCode = 1;
636
638
  }
@@ -834,7 +836,10 @@ async function readPipedStdin() {
834
836
  }
835
837
  main()
836
838
  .then(() => {
837
- void exitAfterFlush(0);
839
+ // Preserve an exit code set by a handled failure path (e.g. print-mode
840
+ // agent errors set exitCode = 1 and return normally) — exit(0) here
841
+ // would silently override it.
842
+ void exitAfterFlush(typeof process.exitCode === "number" ? process.exitCode : 0);
838
843
  })
839
844
  .catch((err) => {
840
845
  console.error(chalk.red(`Fatal error: ${err.message}`));
@@ -42,13 +42,26 @@ export declare class PrintRunCollector {
42
42
  private addUsage;
43
43
  summary(): PrintRunSummary;
44
44
  }
45
+ /**
46
+ * Per-path counts of context compactions during the run. Lets benchmark
47
+ * harnesses correlate outcomes with "did the model ever lose sight of the
48
+ * full history" without access to the in-container session files.
49
+ */
50
+ export interface PrintCompactionStats {
51
+ resident: number;
52
+ llm: number;
53
+ overflow: number;
54
+ droppedMessages: number;
55
+ }
45
56
  export declare function formatPrintJson(input: {
46
57
  summary: PrintRunSummary;
47
58
  sessionId?: string;
48
59
  stopReason?: "end_turn" | "error" | "cancelled";
60
+ compaction?: PrintCompactionStats;
49
61
  }): string;
50
62
  export declare function formatPrintJsonError(input: {
51
63
  message: string;
52
64
  summary?: PrintRunSummary;
53
65
  sessionId?: string;
66
+ compaction?: PrintCompactionStats;
54
67
  }): string;
@@ -83,6 +83,7 @@ export function formatPrintJson(input) {
83
83
  num_tool_calls: input.summary.num_tool_calls,
84
84
  usage: input.summary.usage,
85
85
  usage_reported: input.summary.usage_reported,
86
+ ...(input.compaction ? { compaction: input.compaction } : {}),
86
87
  });
87
88
  }
88
89
  export function formatPrintJsonError(input) {
@@ -99,5 +100,6 @@ export function formatPrintJsonError(input) {
99
100
  usage: input.summary.usage,
100
101
  }
101
102
  : {}),
103
+ ...(input.compaction ? { compaction: input.compaction } : {}),
102
104
  });
103
105
  }
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.39",
3
+ "version": "0.0.41",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {