@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 +3 -0
- package/dist/agent.js +38 -9
- package/dist/context/compact-llm.js +25 -10
- package/dist/context/compact.d.ts +30 -6
- package/dist/context/compact.js +159 -40
- package/dist/context/llm-compactor.js +20 -13
- package/dist/context/projector.js +7 -1
- package/dist/orchestrator/default-hooks.js +26 -2
- package/dist/orchestrator/hooks.d.ts +2 -0
- package/dist/print-output.d.ts +3 -0
- package/dist/prompt/reminders.d.ts +1 -0
- package/dist/prompt/reminders.js +7 -0
- package/dist/prompt/runtime.js +1 -0
- package/dist/tui-ink/display-history.js +7 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
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
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
60
|
-
const keptMessages =
|
|
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(
|
|
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
|
-
...
|
|
76
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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;
|
package/dist/context/compact.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
116
|
-
const keptMessages =
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
...
|
|
130
|
-
...(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
|
|
148
|
-
|
|
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]
|
|
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
|
|
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
|
|
183
|
-
if (!
|
|
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
|
-
...
|
|
282
|
+
...leading.map(cloneMessage),
|
|
194
283
|
...preTurn.map(cloneMessage),
|
|
195
284
|
{
|
|
196
|
-
role: "
|
|
197
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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]
|
|
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
|
|
77
|
-
//
|
|
78
|
-
// instruction is excluded — it survives verbatim
|
|
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
|
-
...
|
|
122
|
-
...(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
|
-
|
|
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[];
|
package/dist/print-output.d.ts
CHANGED
|
@@ -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";
|
package/dist/prompt/reminders.js
CHANGED
|
@@ -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
|
package/dist/prompt/runtime.js
CHANGED
|
@@ -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 ||
|
|
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;
|