@bubblebrain-ai/bubble 0.0.40 → 0.0.42

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
@@ -226,6 +227,7 @@ export declare class Agent {
226
227
  private getActiveToolEntries;
227
228
  injectSystemReminder(content: string): void;
228
229
  injectModeReminder(): void;
230
+ get role(): "parent" | "subagent";
229
231
  get model(): string;
230
232
  set model(value: string);
231
233
  get providerId(): string;
@@ -256,6 +258,13 @@ export declare class Agent {
256
258
  private recoverFromOverflow;
257
259
  compactResidentHistory(): void;
258
260
  private maybeCompactWithLLM;
261
+ /** Snapshot of how often each compaction path rewrote history this run. */
262
+ getCompactionStats(): {
263
+ resident: number;
264
+ llm: number;
265
+ overflow: number;
266
+ droppedMessages: number;
267
+ };
259
268
  /**
260
269
  * Stream a 9-section handoff summary of `oldMessages` from the session model.
261
270
  * 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;
@@ -422,6 +428,9 @@ export class Agent {
422
428
  }
423
429
  this.injectSystemReminder(reminder);
424
430
  }
431
+ get role() {
432
+ return this.agentRole;
433
+ }
425
434
  get model() {
426
435
  return this._model;
427
436
  }
@@ -992,6 +1001,9 @@ export class Agent {
992
1001
  }
993
1002
  const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
994
1003
  consecutiveOverflowRecoveries += 1;
1004
+ this.compactionStats.overflow += 1;
1005
+ this.compactionStats.droppedMessages += droppedMessages;
1006
+ traceEvent("compaction_fired", { path: "overflow", droppedMessages });
995
1007
  yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
996
1008
  continue;
997
1009
  }
@@ -1507,10 +1519,16 @@ export class Agent {
1507
1519
  this.lastInputTokens = null;
1508
1520
  this.lastAnchorMessageCount = null;
1509
1521
  this.fileStateTracker?.invalidateReadHistory();
1522
+ this.compactionStats.llm += 1;
1523
+ traceEvent("compaction_fired", { path: "llm" });
1510
1524
  }
1511
1525
  // If LLM compaction failed for any reason, leave this.messages alone —
1512
1526
  // the projector's algorithmic budgeted-mode passes will still try.
1513
1527
  }
1528
+ /** Snapshot of how often each compaction path rewrote history this run. */
1529
+ getCompactionStats() {
1530
+ return { ...this.compactionStats };
1531
+ }
1514
1532
  /**
1515
1533
  * Stream a 9-section handoff summary of `oldMessages` from the session model.
1516
1534
  * Powers the manual `/compact` command: streaming (rather than `complete()`)
@@ -2537,6 +2555,8 @@ export class Agent {
2537
2555
  const compacted = compactMessages(candidate, { keepRecentTurns });
2538
2556
  if (compacted.compacted && compacted.messages) {
2539
2557
  candidate = compacted.messages;
2558
+ this.compactionStats.resident += 1;
2559
+ traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
2540
2560
  }
2541
2561
  }
2542
2562
  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
  }
@@ -4,7 +4,7 @@ import { EvidenceTracker } from "../agent/evidence-tracker.js";
4
4
  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
- import { buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
7
+ import { buildCompletionSelfCheckReminder, buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
8
8
  import { largeImplementationTaskReminder, orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
9
9
  import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
10
10
  import { resolve as resolvePath } from "node:path";
@@ -220,9 +220,33 @@ export function createDefaultHooks() {
220
220
  }
221
221
  // Verification reminders intentionally removed. See afterToolCall.
222
222
  },
223
- afterTurn() {
223
+ afterTurn(ctx) {
224
224
  // Verification force-continuation removed. The model decides whether
225
225
  // verification is meaningful for the task, per the system prompt.
226
+ //
227
+ // Completion self-check gate: when a run that changed code is about
228
+ // to end (this hook only runs on final, no-tool-call turns), remind
229
+ // the model ONCE to re-read the original request before finishing.
230
+ // Unlike the removed verification gate it never repeats — the model
231
+ // is explicitly told a confirming final answer ends the run — so the
232
+ // "prove it" spiral that killed the old design cannot start.
233
+ if (ctx.state.completionGateFired)
234
+ return;
235
+ if (!ctx.state.codeChanged)
236
+ return;
237
+ // Tools are frozen: the model could not act on anything the check
238
+ // surfaces, so another turn is pure token burn.
239
+ if (ctx.state.forceTextOnlyReason)
240
+ return;
241
+ // Subagents hand off to a parent that does its own closing pass, and
242
+ // worktree profiles explicitly forbid the very actions a completion
243
+ // check would suggest.
244
+ if (ctx.agent.role === "subagent")
245
+ return;
246
+ ctx.state.completionGateFired = true;
247
+ ctx.state.forceContinuationReason = "completion_self_check";
248
+ ctx.queueReminder(buildCompletionSelfCheckReminder());
249
+ traceEvent("completion_gate_fired", { turnCount: ctx.state.turnCount });
226
250
  },
227
251
  },
228
252
  ];
@@ -16,6 +16,8 @@ export interface TurnHookState {
16
16
  forceTextOnlyReason?: string;
17
17
  forceContinuationReason?: string;
18
18
  codeChanged?: boolean;
19
+ /** Completion self-check gate already fired this run (fires at most once). */
20
+ completionGateFired?: boolean;
19
21
  smallTaskHintSent?: boolean;
20
22
  recentEditFailures?: string[];
21
23
  editNoMatchReminderPaths?: string[];
@@ -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
  }
@@ -23,6 +23,7 @@ export declare function buildInvestigationReminder(): string;
23
23
  export declare function buildLoopWarningReminder(reason: string): string;
24
24
  export declare function buildSearchFreezeReminder(reason: string): string;
25
25
  export declare function buildExplorationFreezeReminder(reason: string): string;
26
+ export declare function buildCompletionSelfCheckReminder(): string;
26
27
  export declare function buildToolFreezeReminder(reason: string): string;
27
28
  export declare function buildWorkflowPhaseReminder(input: {
28
29
  phase: "investigate" | "correlate" | "conclude";
@@ -119,6 +119,13 @@ Choose one of:
119
119
  3. If files were already changed, run the narrowest meaningful verification or finish with the result.
120
120
  `);
121
121
  }
122
+ export function buildCompletionSelfCheckReminder() {
123
+ return wrapInSystemReminder(`
124
+ You appear to be finishing. Before giving your final answer, re-read the user's original request in this conversation and check whether any explicit requirement in it remains unfulfilled — including requirements about how the work should be delivered or reported, not only whether the implementation works.
125
+
126
+ If something is missing, complete it now. If everything is done, give your final answer directly — do not repeat this check or re-verify work you have already verified.
127
+ `);
128
+ }
122
129
  export function buildToolFreezeReminder(reason) {
123
130
  return wrapInSystemReminder(`
124
131
  CRITICAL - MAXIMUM STEPS REACHED
@@ -11,6 +11,7 @@ const defaultGuidelines = [
11
11
  "Prefer structured tools (glob, grep, read) over bash for search and inspection. Do not repeat a near-identical search or re-read the same file unless new evidence changes the question.",
12
12
  "If a tool fails, diagnose the error before switching tactics. Do not retry the identical call with identical arguments. After two equivalent failures, switch approach — re-read the file, use a different tool, rewrite the whole file with write, or ask the user.",
13
13
  "Before reporting a task complete, verify it works when verification is meaningful and cheap — run the existing test, execute the script, check the output. If no test exists, the change is purely declarative (static HTML/markdown/config), or running the code is not practical, state that explicitly rather than inventing a verification step. Do not write throwaway validation scripts to prove correctness; if there is no real check to run, report the change and stop.",
14
+ "Before declaring a task complete, re-read the user's original request and verify that every explicit requirement in it — about both the result and the process — has been fulfilled, not only that the implementation works.",
14
15
  ];
15
16
  export function buildRuntimePrompt(options = {}) {
16
17
  const guidelines = dedupe(defaultGuidelines, options.guidelines ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {