@bubblebrain-ai/bubble 0.0.41 → 0.0.43

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.
package/dist/agent.d.ts CHANGED
@@ -227,6 +227,7 @@ export declare class Agent {
227
227
  private getActiveToolEntries;
228
228
  injectSystemReminder(content: string): void;
229
229
  injectModeReminder(): void;
230
+ get role(): "parent" | "subagent";
230
231
  get model(): string;
231
232
  set model(value: string);
232
233
  get providerId(): string;
@@ -260,8 +261,10 @@ export declare class Agent {
260
261
  /** Snapshot of how often each compaction path rewrote history this run. */
261
262
  getCompactionStats(): {
262
263
  resident: number;
264
+ subturn: number;
263
265
  llm: number;
264
266
  overflow: number;
267
+ fired: number;
265
268
  droppedMessages: number;
266
269
  };
267
270
  /**
package/dist/agent.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Agent - The core decision loop.
3
3
  * It maintains message state, calls the LLM, executes tools, and auto-continues.
4
4
  */
5
- import { compactMessages } from "./context/compact.js";
5
+ import { compactCurrentTurnToolGroups, compactMessages, isRealUserMessage } from "./context/compact.js";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { mkdirSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
@@ -175,9 +175,10 @@ export class Agent {
175
175
  // How often each compaction path actually rewrote history this run.
176
176
  // Surfaced via getCompactionStats() for print-mode telemetry: without it,
177
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 };
178
+ // unanswerable from the outside. `fired` counts successful compaction
179
+ // computations including ones whose rewrite was later rejected — a
180
+ // fired-but-never-written gap is the churn signal.
181
+ compactionStats = { resident: 0, subturn: 0, llm: 0, overflow: 0, fired: 0, droppedMessages: 0 };
181
182
  constructor(options) {
182
183
  this.provider = options.provider;
183
184
  this.sessionID = options.sessionID;
@@ -428,6 +429,9 @@ export class Agent {
428
429
  }
429
430
  this.injectSystemReminder(reminder);
430
431
  }
432
+ get role() {
433
+ return this.agentRole;
434
+ }
431
435
  get model() {
432
436
  return this._model;
433
437
  }
@@ -999,6 +1003,7 @@ export class Agent {
999
1003
  const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
1000
1004
  consecutiveOverflowRecoveries += 1;
1001
1005
  this.compactionStats.overflow += 1;
1006
+ this.compactionStats.fired += 1;
1002
1007
  this.compactionStats.droppedMessages += droppedMessages;
1003
1008
  traceEvent("compaction_fired", { path: "overflow", droppedMessages });
1004
1009
  yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
@@ -1517,6 +1522,7 @@ export class Agent {
1517
1522
  this.lastAnchorMessageCount = null;
1518
1523
  this.fileStateTracker?.invalidateReadHistory();
1519
1524
  this.compactionStats.llm += 1;
1525
+ this.compactionStats.fired += 1;
1520
1526
  traceEvent("compaction_fired", { path: "llm" });
1521
1527
  }
1522
1528
  // If LLM compaction failed for any reason, leave this.messages alone —
@@ -2548,23 +2554,43 @@ export class Agent {
2548
2554
  if (shouldAggressivelyPrune) {
2549
2555
  candidate = aggressivePruneMessages(candidate);
2550
2556
  }
2557
+ let compactedPath;
2551
2558
  if (shouldCompact) {
2552
2559
  const compacted = compactMessages(candidate, { keepRecentTurns });
2553
2560
  if (compacted.compacted && compacted.messages) {
2554
2561
  candidate = compacted.messages;
2555
- this.compactionStats.resident += 1;
2562
+ compactedPath = "resident";
2556
2563
  traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
2557
2564
  }
2565
+ else {
2566
+ // Single-instruction runs (one real user turn) never satisfy the
2567
+ // turn-level compactor; fold old tool groups within the turn instead.
2568
+ const subturn = compactCurrentTurnToolGroups(candidate, { keepRecentGroups: 3 });
2569
+ if (subturn.compacted && subturn.messages) {
2570
+ candidate = subturn.messages;
2571
+ compactedPath = "subturn";
2572
+ traceEvent("compaction_fired", { path: "subturn", droppedEntries: subturn.droppedEntries });
2573
+ }
2574
+ }
2575
+ if (compactedPath)
2576
+ this.compactionStats.fired += 1;
2558
2577
  }
2559
2578
  const afterChars = estimateResidentChars(candidate);
2560
2579
  const afterToolChars = estimateToolPayloadChars(candidate);
2561
- if (afterChars < beforeChars
2562
- || afterToolChars < beforeToolChars
2563
- || candidate.length < before.length) {
2580
+ // Chars-not-increasing is a NECESSARY condition: the old OR gate let a
2581
+ // fewer-but-bigger candidate (summary stacking) rewrite history.
2582
+ if (afterChars <= beforeChars
2583
+ && (afterChars < beforeChars
2584
+ || afterToolChars < beforeToolChars
2585
+ || candidate.length < before.length)) {
2564
2586
  this.messages = candidate;
2565
2587
  this.lastInputTokens = null;
2566
2588
  this.lastAnchorMessageCount = null;
2567
2589
  this.fileStateTracker?.invalidateReadHistory();
2590
+ if (compactedPath === "resident")
2591
+ this.compactionStats.resident += 1;
2592
+ if (compactedPath === "subturn")
2593
+ this.compactionStats.subturn += 1;
2568
2594
  }
2569
2595
  }
2570
2596
  appendMessage(message) {
@@ -2943,7 +2969,10 @@ function estimateToolPayloadChars(messages) {
2943
2969
  }, 0);
2944
2970
  }
2945
2971
  function countUserTurns(messages) {
2946
- return messages.reduce((count, message) => count + (message.role === "user" ? 1 : 0), 0);
2972
+ // Real user turns only: projected reminders and summary envelopes are
2973
+ // user-ROLE but not user TURNS — counting them used to shrink the keep
2974
+ // window and fire compaction almost every turn.
2975
+ return messages.reduce((count, message) => count + (isRealUserMessage(message) ? 1 : 0), 0);
2947
2976
  }
2948
2977
  function getCurrentHeapUsed() {
2949
2978
  try {
@@ -6,7 +6,7 @@
6
6
  * message. Falls back to the heuristic `compactMessages` if the LLM call
7
7
  * fails.
8
8
  */
9
- import { compactMessages as compactMessagesHeuristic } from "./compact.js";
9
+ import { buildCompactionSummaryMessage, clonePinnedUserMessage, compactMessages as compactMessagesHeuristic, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
10
10
  export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your job is to produce a structured
11
11
  summary of an earlier portion of a software-engineering assistant's
12
12
  conversation so that the assistant can continue working without the full
@@ -44,10 +44,13 @@ content, write "None".
44
44
  - The single most natural next action, if obvious.`;
45
45
  export async function compactMessagesWithLLM(messages, options) {
46
46
  const keepRecentTurns = options.keepRecentTurns ?? 2;
47
- const preservedContextMessages = messages.filter((m) => m.role === "system" || m.role === "meta");
48
- const conversationalMessages = messages.filter((m) => m.role !== "system" && m.role !== "meta");
49
- const turnStartIndexes = conversationalMessages
50
- .map((m, i) => (m.role === "user" ? i : -1))
47
+ const { leading, body } = splitLeadingContext(messages);
48
+ // Replace semantics: prior summaries (any form) are folded into the new
49
+ // summary as INPUT (the LLM merges them semantically) and removed.
50
+ const priorSummaries = body.filter(isCompactionSummaryMessage);
51
+ const bodyMessages = body.filter((m) => !isCompactionSummaryMessage(m));
52
+ const turnStartIndexes = bodyMessages
53
+ .map((m, i) => (isRealUserMessage(m) ? i : -1))
51
54
  .filter((i) => i >= 0);
52
55
  if (turnStartIndexes.length <= keepRecentTurns) {
53
56
  return { compacted: false };
@@ -56,11 +59,22 @@ export async function compactMessagesWithLLM(messages, options) {
56
59
  if (keepStartIndex <= 0) {
57
60
  return { compacted: false };
58
61
  }
59
- const oldMessages = conversationalMessages.slice(0, keepStartIndex);
60
- const keptMessages = conversationalMessages.slice(keepStartIndex);
62
+ const oldMessages = bodyMessages.slice(0, keepStartIndex);
63
+ const keptMessages = bodyMessages.slice(keepStartIndex);
64
+ // Pin the original instruction verbatim (P0.5: this path used to lack the
65
+ // pin the heuristic and llm-compactor paths already had).
66
+ const pinnedIndex = findFirstRealUserIndex(oldMessages);
67
+ const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
68
+ const summarizable = [
69
+ ...priorSummaries.map((m) => ({
70
+ role: "user",
71
+ content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
72
+ })),
73
+ ...oldMessages.filter((_, index) => index !== pinnedIndex),
74
+ ];
61
75
  let summary;
62
76
  try {
63
- summary = await generateSummary(oldMessages, options);
77
+ summary = await generateSummary(summarizable, options);
64
78
  }
65
79
  catch {
66
80
  return compactMessagesHeuristic(messages, { keepRecentTurns, maxSummaryItems: options.maxSummaryItems });
@@ -72,8 +86,9 @@ export async function compactMessagesWithLLM(messages, options) {
72
86
  compacted: true,
73
87
  summary,
74
88
  messages: [
75
- ...preservedContextMessages,
76
- { role: "system", content: `Previous conversation summary:\n${summary}` },
89
+ ...leading,
90
+ ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
91
+ buildCompactionSummaryMessage(summary),
77
92
  ...keptMessages,
78
93
  ],
79
94
  droppedEntries: oldMessages.length,
@@ -4,17 +4,41 @@ import type { SessionLogEntry } from "../session-types.js";
4
4
  * Compaction must never summarize away what the user actually asked for:
5
5
  * requirements that appear late in a long instruction ("commit when done",
6
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.
7
+ * summary truncates the goal line. Messages larger than this cap are pinned
8
+ * in truncated form (e.g. a pasted megabyte of logs as the opening message).
10
9
  */
11
10
  export declare const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
+ export declare const COMPACTION_SUMMARY_PREFIX = "Previous conversation summary:";
12
+ export declare const SUBTURN_SUMMARY_PREFIX = "Earlier in this turn (compacted to free context):";
12
13
  /**
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.
14
+ * Identity check for compaction summaries across every form they can take:
15
+ * the first-class meta form, the legacy raw-system form, and the projected
16
+ * user-role internal-block forms (the resident path rewrites projections back
17
+ * into history, so role/position are not conserved).
16
18
  */
19
+ export declare function isCompactionSummaryMessage(message: Message): boolean;
20
+ /** A message the user actually typed (not a projected reminder, not a summary). */
21
+ export declare function isRealUserMessage(message: Message): boolean;
22
+ /**
23
+ * Split history into the leading system/meta context prefix (system prompt,
24
+ * startup reminders) and the conversational body. Positional, mirroring the
25
+ * projector's leading-prefix semantics: mid-history system/meta messages
26
+ * belong to the body and are foldable — preserving them by role forever is
27
+ * how stale reminders and relocated summaries used to pile up at the head.
28
+ */
29
+ export declare function splitLeadingContext(messages: Message[]): {
30
+ leading: Message[];
31
+ body: Message[];
32
+ };
33
+ /** Index of the first message the user actually wrote. */
17
34
  export declare function findFirstRealUserIndex(messages: Message[]): number;
35
+ /**
36
+ * Clone a message for pinning; oversized content is truncated rather than
37
+ * losing the pin entirely (P0.5: a truncated original beats a 140-char
38
+ * summary line).
39
+ */
40
+ export declare function clonePinnedUserMessage(message: Message): Message;
41
+ export declare function buildCompactionSummaryMessage(summary: string): Message;
18
42
  export interface CompactOptions {
19
43
  keepRecentTurns?: number;
20
44
  maxSummaryItems?: number;
@@ -3,12 +3,27 @@ import { isInternalBlockContent } from "../agent/internal-reminder-sanitizer.js"
3
3
  * Compaction must never summarize away what the user actually asked for:
4
4
  * requirements that appear late in a long instruction ("commit when done",
5
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.
6
+ * summary truncates the goal line. Messages larger than this cap are pinned
7
+ * in truncated form (e.g. a pasted megabyte of logs as the opening message).
9
8
  */
10
9
  export const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
- function userMessageText(message) {
10
+ export const COMPACTION_SUMMARY_PREFIX = "Previous conversation summary:";
11
+ export const SUBTURN_SUMMARY_PREFIX = "Earlier in this turn (compacted to free context):";
12
+ // llm-compactor.ts's LLM_SUMMARY_PREFIX must start with this literal — a unit
13
+ // test locks the two together (importing it here would create a module cycle).
14
+ const LLM_ENVELOPE_PREFIX = "Another language model previously worked on this task";
15
+ // Projected forms: the projector wraps meta messages into user-role internal
16
+ // blocks, and the resident-history path writes that projected form back, so a
17
+ // summary emitted as meta can come back around as one of these.
18
+ const PROJECTED_SUMMARY_PREFIXES = [
19
+ `<bubble_internal_context kind="compaction-summary">`,
20
+ `<bubble_internal_context kind="subturn-compaction-summary">`,
21
+ // Legacy: summaries emitted as raw system messages by ≤0.0.42 got projected
22
+ // into runtime-system blocks.
23
+ `<bubble_internal_context kind="runtime-system">\n${COMPACTION_SUMMARY_PREFIX}`,
24
+ `<bubble_internal_context kind="runtime-system">\n${SUBTURN_SUMMARY_PREFIX}`,
25
+ ];
26
+ function messageText(message) {
12
27
  if (typeof message.content === "string")
13
28
  return message.content;
14
29
  if (Array.isArray(message.content)) {
@@ -20,24 +35,86 @@ function userMessageText(message) {
20
35
  return "";
21
36
  }
22
37
  /**
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.
38
+ * Identity check for compaction summaries across every form they can take:
39
+ * the first-class meta form, the legacy raw-system form, and the projected
40
+ * user-role internal-block forms (the resident path rewrites projections back
41
+ * into history, so role/position are not conserved).
26
42
  */
43
+ export function isCompactionSummaryMessage(message) {
44
+ if (message.role === "meta") {
45
+ return message.kind === "compaction-summary" || message.kind === "subturn-compaction-summary";
46
+ }
47
+ const text = messageText(message);
48
+ if (message.role === "system") {
49
+ return text.startsWith(COMPACTION_SUMMARY_PREFIX) || text.startsWith(SUBTURN_SUMMARY_PREFIX);
50
+ }
51
+ if (message.role === "user") {
52
+ const trimmed = text.trimStart();
53
+ if (trimmed.startsWith(LLM_ENVELOPE_PREFIX))
54
+ return true;
55
+ return PROJECTED_SUMMARY_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
56
+ }
57
+ return false;
58
+ }
59
+ /** A message the user actually typed (not a projected reminder, not a summary). */
60
+ export function isRealUserMessage(message) {
61
+ if (message.role !== "user")
62
+ return false;
63
+ const text = messageText(message);
64
+ if (!text.trim() || isInternalBlockContent(text))
65
+ return false;
66
+ if (text.trimStart().startsWith(LLM_ENVELOPE_PREFIX))
67
+ return false;
68
+ return true;
69
+ }
70
+ /**
71
+ * Split history into the leading system/meta context prefix (system prompt,
72
+ * startup reminders) and the conversational body. Positional, mirroring the
73
+ * projector's leading-prefix semantics: mid-history system/meta messages
74
+ * belong to the body and are foldable — preserving them by role forever is
75
+ * how stale reminders and relocated summaries used to pile up at the head.
76
+ */
77
+ export function splitLeadingContext(messages) {
78
+ let split = 0;
79
+ while (split < messages.length) {
80
+ const message = messages[split];
81
+ if ((message.role === "system" || message.role === "meta") && !isCompactionSummaryMessage(message)) {
82
+ split += 1;
83
+ continue;
84
+ }
85
+ break;
86
+ }
87
+ return { leading: messages.slice(0, split), body: messages.slice(split) };
88
+ }
89
+ /** Index of the first message the user actually wrote. */
27
90
  export function findFirstRealUserIndex(messages) {
28
91
  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;
92
+ if (isRealUserMessage(messages[index]))
93
+ return index;
38
94
  }
39
95
  return -1;
40
96
  }
97
+ /**
98
+ * Clone a message for pinning; oversized content is truncated rather than
99
+ * losing the pin entirely (P0.5: a truncated original beats a 140-char
100
+ * summary line).
101
+ */
102
+ export function clonePinnedUserMessage(message) {
103
+ const text = messageText(message);
104
+ if (text.length <= PINNED_INSTRUCTION_MAX_CHARS)
105
+ return cloneMessage(message);
106
+ return {
107
+ role: "user",
108
+ content: `${text.slice(0, PINNED_INSTRUCTION_MAX_CHARS)}\n[...original message truncated for context management...]`,
109
+ };
110
+ }
111
+ export function buildCompactionSummaryMessage(summary) {
112
+ return {
113
+ role: "meta",
114
+ kind: "compaction-summary",
115
+ content: `${COMPACTION_SUMMARY_PREFIX}\n${summary}`,
116
+ };
117
+ }
41
118
  export function planSessionCompaction(entries, options = {}) {
42
119
  const keepRecentTurns = options.keepRecentTurns ?? 2;
43
120
  const metadataEntries = entries.filter((entry) => entry.type === "metadata");
@@ -100,10 +177,17 @@ export function compactSessionEntries(entries, options = {}) {
100
177
  export function compactMessages(messages, options = {}) {
101
178
  const keepRecentTurns = options.keepRecentTurns ?? 2;
102
179
  const maxSummaryItems = options.maxSummaryItems ?? 4;
103
- const preservedContextMessages = messages.filter((message) => message.role === "system" || message.role === "meta");
104
- const conversationalMessages = messages.filter((message) => message.role !== "system" && message.role !== "meta");
105
- const turnStartIndexes = conversationalMessages
106
- .map((message, index) => (message.role === "user" ? index : -1))
180
+ // Replace semantics: prior summaries (any form, any position) die here —
181
+ // at most one summary exists after every compaction. Their content is
182
+ // template-grade (goal truncated to 140 chars, constant next-steps line);
183
+ // the pinned original instruction is the durable record, not old summaries.
184
+ const { leading, body } = splitLeadingContext(messages);
185
+ const bodyMessages = body.filter((message) => !isCompactionSummaryMessage(message));
186
+ // Turn boundaries anchor on REAL user messages only. Projected reminders
187
+ // and summary envelopes used to inflate the turn count, shrinking the keep
188
+ // window to nothing and firing compaction almost every turn.
189
+ const turnStartIndexes = bodyMessages
190
+ .map((message, index) => (isRealUserMessage(message) ? index : -1))
107
191
  .filter((index) => index >= 0);
108
192
  if (turnStartIndexes.length <= keepRecentTurns) {
109
193
  return { compacted: false };
@@ -112,26 +196,25 @@ export function compactMessages(messages, options = {}) {
112
196
  if (keepStartIndex <= 0) {
113
197
  return { compacted: false };
114
198
  }
115
- const oldMessages = conversationalMessages.slice(0, keepStartIndex);
116
- const keptMessages = conversationalMessages.slice(keepStartIndex);
199
+ const oldMessages = bodyMessages.slice(0, keepStartIndex);
200
+ const keptMessages = bodyMessages.slice(keepStartIndex);
117
201
  // Pin the original instruction: it stays verbatim above the summary instead
118
202
  // of being crushed into the summary's 140-char goal line.
119
203
  const pinnedIndex = findFirstRealUserIndex(oldMessages);
120
204
  const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
121
- const summaryInput = pinnedIndex >= 0
122
- ? oldMessages.filter((_, index) => index !== pinnedIndex)
123
- : oldMessages;
205
+ // Summary fodder: real conversational content only — no pinned original,
206
+ // no projected reminder blocks (they used to pollute Goal/Progress lines).
207
+ const summaryInput = oldMessages.filter((message, index) => index !== pinnedIndex
208
+ && message.role !== "meta"
209
+ && !(message.role === "user" && !isRealUserMessage(message)));
124
210
  const summary = buildMessageSummary(summaryInput, maxSummaryItems);
125
211
  if (!summary) {
126
212
  return { compacted: false };
127
213
  }
128
214
  const compactedMessages = [
129
- ...preservedContextMessages.map((message) => cloneMessage(message)),
130
- ...(pinnedMessage ? [cloneMessage(pinnedMessage)] : []),
131
- {
132
- role: "system",
133
- content: `Previous conversation summary:\n${summary}`,
134
- },
215
+ ...leading.map((message) => cloneMessage(message)),
216
+ ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
217
+ buildCompactionSummaryMessage(summary),
135
218
  ...keptMessages.map((message) => cloneMessage(message)),
136
219
  ];
137
220
  return {
@@ -144,11 +227,12 @@ export function compactMessages(messages, options = {}) {
144
227
  export function compactCurrentTurnToolGroups(messages, options = {}) {
145
228
  const keepRecentGroups = options.keepRecentGroups ?? 2;
146
229
  const maxSummaryItems = options.maxSummaryItems ?? 8;
147
- const preserved = messages.filter((m) => m.role === "system" || m.role === "meta");
148
- const body = messages.filter((m) => m.role !== "system" && m.role !== "meta");
230
+ const { leading, body } = splitLeadingContext(messages);
231
+ // Anchor the "current turn" on the last REAL user message — a projected
232
+ // reminder must not shrink the turn to a sliver.
149
233
  let lastUserIndex = -1;
150
234
  for (let i = body.length - 1; i >= 0; i--) {
151
- if (body[i].role === "user") {
235
+ if (isRealUserMessage(body[i])) {
152
236
  lastUserIndex = i;
153
237
  break;
154
238
  }
@@ -156,7 +240,11 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
156
240
  if (lastUserIndex < 0)
157
241
  return { compacted: false };
158
242
  const preTurn = body.slice(0, lastUserIndex + 1);
159
- const turnBody = body.slice(lastUserIndex + 1);
243
+ const rawTurnBody = body.slice(lastUserIndex + 1);
244
+ // Replace semantics within the turn: fold prior sub-turn summaries' lines
245
+ // into the new one instead of stacking marker messages.
246
+ const priorSubturnSummaries = rawTurnBody.filter((m) => m.role === "meta" ? m.kind === "subturn-compaction-summary" : isCompactionSummaryMessage(m) && messageText(m).includes(SUBTURN_SUMMARY_PREFIX.slice(0, 20)));
247
+ const turnBody = rawTurnBody.filter((m) => !priorSubturnSummaries.includes(m));
160
248
  const groups = [];
161
249
  let current = null;
162
250
  for (const msg of turnBody) {
@@ -179,9 +267,10 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
179
267
  .filter((g) => g.assistant.role === "assistant" && (g.assistant.toolCalls?.length ?? 0) > 0);
180
268
  if (evictable.length === 0)
181
269
  return { compacted: false };
182
- const summary = buildToolGroupsSummary(evictable, maxSummaryItems);
183
- if (!summary)
270
+ const freshSummary = buildToolGroupsSummary(evictable, maxSummaryItems);
271
+ if (!freshSummary)
184
272
  return { compacted: false };
273
+ const summary = mergeSubturnSummaryLines(priorSubturnSummaries, freshSummary, maxSummaryItems);
185
274
  const survivingGroups = groups.filter((g) => !evictable.includes(g));
186
275
  const flatSurvivors = [];
187
276
  for (const g of survivingGroups) {
@@ -190,11 +279,12 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
190
279
  flatSurvivors.push(cloneMessage(t));
191
280
  }
192
281
  const compactedMessages = [
193
- ...preserved.map(cloneMessage),
282
+ ...leading.map(cloneMessage),
194
283
  ...preTurn.map(cloneMessage),
195
284
  {
196
- role: "system",
197
- content: `Earlier in this turn (compacted to free context):\n${summary}`,
285
+ role: "meta",
286
+ kind: "subturn-compaction-summary",
287
+ content: `${SUBTURN_SUMMARY_PREFIX}\n${summary}`,
198
288
  },
199
289
  ...flatSurvivors,
200
290
  ];
@@ -205,6 +295,35 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
205
295
  droppedEntries: evictable.length,
206
296
  };
207
297
  }
298
+ /**
299
+ * Merge prior sub-turn summary lines with the fresh one: line-level dedupe,
300
+ * prior lines first (chronological), capped. Structured merge of tool-name /
301
+ * file lists — not freetext concatenation.
302
+ */
303
+ function mergeSubturnSummaryLines(priorSummaries, freshSummary, maxItems) {
304
+ if (priorSummaries.length === 0)
305
+ return freshSummary;
306
+ const seen = new Set();
307
+ const merged = [];
308
+ const push = (line) => {
309
+ const normalized = line.trim();
310
+ if (!normalized || seen.has(normalized))
311
+ return;
312
+ seen.add(normalized);
313
+ merged.push(line);
314
+ };
315
+ for (const prior of priorSummaries) {
316
+ for (const line of messageText(prior).split("\n")) {
317
+ if (line.startsWith(SUBTURN_SUMMARY_PREFIX) || line.startsWith("<bubble_internal_"))
318
+ continue;
319
+ push(line);
320
+ }
321
+ }
322
+ for (const line of freshSummary.split("\n"))
323
+ push(line);
324
+ const cap = Math.max(maxItems * 4, 24);
325
+ return merged.slice(-cap).join("\n");
326
+ }
208
327
  function buildToolGroupsSummary(groups, maxItems) {
209
328
  const toolCounts = new Map();
210
329
  const fileSet = new Set();
@@ -10,7 +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
+ import { buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
14
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.
15
15
 
16
16
  Include:
@@ -25,11 +25,17 @@ export async function compactWithLLM(messages, options) {
25
25
  const { provider, modelId, abortSignal } = options;
26
26
  const maxInputTokens = options.maxInputTokens ?? 100_000;
27
27
  const keepRecentGroups = options.keepRecentGroups ?? 2;
28
- const preserved = messages.filter((m) => m.role === "system" || m.role === "meta");
29
- const body = messages.filter((m) => m.role !== "system" && m.role !== "meta");
28
+ // Positional leading prefix, not role-global filtering: filtering by role
29
+ // used to relocate mid-history system messages (including prior summaries)
30
+ // to the head, where the projector then fused them into the system prompt.
31
+ const { leading, body: rawBody } = splitLeadingContext(messages);
32
+ // Replace semantics: prior summaries become summarization INPUT (semantic
33
+ // rolling — the compactor model merges them), never preserved copies.
34
+ const priorSummaries = rawBody.filter(isCompactionSummaryMessage);
35
+ const body = rawBody.filter((m) => !isCompactionSummaryMessage(m));
30
36
  let lastUserIndex = -1;
31
37
  for (let i = body.length - 1; i >= 0; i--) {
32
- if (body[i].role === "user") {
38
+ if (isRealUserMessage(body[i])) {
33
39
  lastUserIndex = i;
34
40
  break;
35
41
  }
@@ -73,10 +79,14 @@ export async function compactWithLLM(messages, options) {
73
79
  const keptGroupCount = Math.min(keepRecentGroups, groups.length);
74
80
  const evictedGroups = groups.slice(0, groups.length - keptGroupCount);
75
81
  const keptGroups = groups.slice(groups.length - keptGroupCount);
76
- // What we'll send to the model to summarize: prior turns + the older groups
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.
82
+ // What we'll send to the model to summarize: prior summaries (semantic
83
+ // rolling), prior turns, and the older groups in the current turn. The
84
+ // pinned first instruction is excluded — it survives verbatim.
79
85
  const toSummarize = [
86
+ ...priorSummaries.map((m) => ({
87
+ role: "user",
88
+ content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
89
+ })),
80
90
  ...summarizablePriorTurns,
81
91
  ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
82
92
  ];
@@ -118,12 +128,9 @@ export async function compactWithLLM(messages, options) {
118
128
  flatKept.push(cloneMessage(t));
119
129
  }
120
130
  const compacted = [
121
- ...preserved.map(cloneMessage),
122
- ...(pinnedFirstUser ? [cloneMessage(pinnedFirstUser)] : []),
123
- {
124
- role: "user",
125
- content: `${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`,
126
- },
131
+ ...leading.map(cloneMessage),
132
+ ...(pinnedFirstUser ? [clonePinnedUserMessage(pinnedFirstUser)] : []),
133
+ buildCompactionSummaryMessage(`${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`),
127
134
  cloneMessage(lastUser),
128
135
  ...flatKept,
129
136
  ];
@@ -85,7 +85,13 @@ export function projectMessages(messages, options = {}) {
85
85
  if (!after.shouldCompact)
86
86
  break;
87
87
  }
88
- return repairToolCallChains(working);
88
+ // Compaction passes emit summaries as meta messages (first-class identity);
89
+ // the provider payload must stay provider-safe, so render them here the
90
+ // same way body meta messages are rendered above.
91
+ const providerSafe = working.map((message) => message.role === "meta"
92
+ ? { role: "user", content: formatMetaMessage(message) }
93
+ : message);
94
+ return repairToolCallChains(providerSafe);
89
95
  }
90
96
  return repaired;
91
97
  }
@@ -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[];
@@ -49,8 +49,11 @@ export declare class PrintRunCollector {
49
49
  */
50
50
  export interface PrintCompactionStats {
51
51
  resident: number;
52
+ subturn: number;
52
53
  llm: number;
53
54
  overflow: number;
55
+ /** Successful compaction computations incl. rejected rewrites (churn signal). */
56
+ fired: number;
54
57
  droppedMessages: number;
55
58
  }
56
59
  export declare function formatPrintJson(input: {
@@ -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 ?? []);
@@ -157,7 +157,13 @@ const TURN_SUMMARY_PREFIX = /^Earlier in this turn \(compacted to free context\)
157
157
  export function latestCompactionSummary(agentMessages) {
158
158
  for (let index = agentMessages.length - 1; index >= 0; index--) {
159
159
  const message = agentMessages[index];
160
- if (!message || message.role !== "system" || typeof message.content !== "string")
160
+ if (!message || typeof message.content !== "string")
161
+ continue;
162
+ // Summaries are meta messages since 0.0.43; raw system form is legacy.
163
+ const isSummaryCarrier = message.role === "system"
164
+ || (message.role === "meta"
165
+ && (message.kind === "compaction-summary" || message.kind === "subturn-compaction-summary"));
166
+ if (!isSummaryCarrier)
161
167
  continue;
162
168
  const previousMatch = message.content.match(PREVIOUS_SUMMARY_PREFIX);
163
169
  if (previousMatch?.[1]?.trim())
package/dist/types.d.ts CHANGED
@@ -64,7 +64,7 @@ export interface SystemMessage {
64
64
  role: "system";
65
65
  content: string;
66
66
  }
67
- export type MetaMessageKind = "system-reminder" | "runtime-context";
67
+ export type MetaMessageKind = "system-reminder" | "runtime-context" | "compaction-summary" | "subturn-compaction-summary";
68
68
  export interface MetaMessage {
69
69
  role: "meta";
70
70
  kind: MetaMessageKind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {