@bubblebrain-ai/bubble 0.0.42 → 0.0.44
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/change-tracker.d.ts +40 -0
- package/dist/agent/change-tracker.js +89 -0
- package/dist/agent/profiles.js +1 -1
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +39 -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/main.js +22 -0
- package/dist/orchestrator/default-hooks.js +31 -7
- package/dist/orchestrator/hooks.d.ts +5 -0
- package/dist/print-output.d.ts +17 -0
- package/dist/print-output.js +2 -0
- package/dist/prompt/reminders.d.ts +8 -1
- package/dist/prompt/reminders.js +21 -6
- package/dist/prompt/task-reminders.js +1 -1
- package/dist/tui-ink/display-history.js +7 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ground-truth change tracking for a single agent run.
|
|
3
|
+
*
|
|
4
|
+
* Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
|
|
5
|
+
* other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
|
|
6
|
+
* merges. Git sees them all. A baseline snapshot is taken at run start; the
|
|
7
|
+
* delta at completion time is the run's actual footprint.
|
|
8
|
+
*
|
|
9
|
+
* Everything here degrades to null outside a git repo or on any git error —
|
|
10
|
+
* callers fall back to metadata-based signals.
|
|
11
|
+
*/
|
|
12
|
+
export interface GitChangeBaseline {
|
|
13
|
+
/** Paths already dirty (or untracked) before the run started. */
|
|
14
|
+
dirtyAtStart: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
export interface ModifiedExistingTest {
|
|
17
|
+
path: string;
|
|
18
|
+
deletedLines: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RunChangeSummary {
|
|
21
|
+
/** Paths that became dirty during the run (not dirty at baseline). */
|
|
22
|
+
changedFiles: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Pre-existing (tracked) test files that were modified during the run,
|
|
25
|
+
* with how many lines the working tree deletes from them vs HEAD. New test
|
|
26
|
+
* files created by the run are NOT listed — creating tests is not suspect.
|
|
27
|
+
*/
|
|
28
|
+
modifiedExistingTests: ModifiedExistingTest[];
|
|
29
|
+
}
|
|
30
|
+
export declare function isTestFilePath(path: string): boolean;
|
|
31
|
+
export declare function captureGitBaseline(cwd: string): Promise<GitChangeBaseline | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Diff the current working tree against the run-start baseline.
|
|
34
|
+
*
|
|
35
|
+
* Known honest limits: a file already dirty at baseline that the run edited
|
|
36
|
+
* further is not attributed to the run (indistinguishable without content
|
|
37
|
+
* snapshots), and deleted-line counts for such files would mix user and agent
|
|
38
|
+
* edits — so they are excluded entirely.
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectRunChanges(cwd: string, baseline: GitChangeBaseline | null): Promise<RunChangeSummary | null>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ground-truth change tracking for a single agent run.
|
|
3
|
+
*
|
|
4
|
+
* Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
|
|
5
|
+
* other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
|
|
6
|
+
* merges. Git sees them all. A baseline snapshot is taken at run start; the
|
|
7
|
+
* delta at completion time is the run's actual footprint.
|
|
8
|
+
*
|
|
9
|
+
* Everything here degrades to null outside a git repo or on any git error —
|
|
10
|
+
* callers fall back to metadata-based signals.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
14
|
+
const TEST_PATH_PATTERN = /(^|\/)(tests?|__tests__|spec)(\/|$)|(\.|_|-)(test|tests|spec)\.[^/]+$|(^|\/)test_[^/]+$/i;
|
|
15
|
+
export function isTestFilePath(path) {
|
|
16
|
+
return TEST_PATH_PATTERN.test(path);
|
|
17
|
+
}
|
|
18
|
+
function git(cwd, args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
|
|
21
|
+
resolve(error ? null : stdout);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** `git status --porcelain -z` → set of paths with any dirty/untracked state. */
|
|
26
|
+
async function dirtyPaths(cwd) {
|
|
27
|
+
const out = await git(cwd, ["status", "--porcelain", "-uall", "-z"]);
|
|
28
|
+
if (out === null)
|
|
29
|
+
return null;
|
|
30
|
+
const paths = new Set();
|
|
31
|
+
for (const entry of out.split("\0")) {
|
|
32
|
+
if (entry.length < 4)
|
|
33
|
+
continue;
|
|
34
|
+
// "XY path" (rename entries "XY new\0old" — the extra segment lacks the
|
|
35
|
+
// status prefix and is shorter than 4 chars only when degenerate; treat
|
|
36
|
+
// every segment with the status shape as an entry).
|
|
37
|
+
const path = entry.slice(3);
|
|
38
|
+
if (path)
|
|
39
|
+
paths.add(path);
|
|
40
|
+
}
|
|
41
|
+
return paths;
|
|
42
|
+
}
|
|
43
|
+
export async function captureGitBaseline(cwd) {
|
|
44
|
+
const dirty = await dirtyPaths(cwd);
|
|
45
|
+
if (dirty === null)
|
|
46
|
+
return null;
|
|
47
|
+
return { dirtyAtStart: dirty };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Diff the current working tree against the run-start baseline.
|
|
51
|
+
*
|
|
52
|
+
* Known honest limits: a file already dirty at baseline that the run edited
|
|
53
|
+
* further is not attributed to the run (indistinguishable without content
|
|
54
|
+
* snapshots), and deleted-line counts for such files would mix user and agent
|
|
55
|
+
* edits — so they are excluded entirely.
|
|
56
|
+
*/
|
|
57
|
+
export async function detectRunChanges(cwd, baseline) {
|
|
58
|
+
if (!baseline)
|
|
59
|
+
return null;
|
|
60
|
+
const dirtyNow = await dirtyPaths(cwd);
|
|
61
|
+
if (dirtyNow === null)
|
|
62
|
+
return null;
|
|
63
|
+
const changedFiles = [...dirtyNow].filter((path) => !baseline.dirtyAtStart.has(path)).sort();
|
|
64
|
+
if (changedFiles.length === 0) {
|
|
65
|
+
return { changedFiles, modifiedExistingTests: [] };
|
|
66
|
+
}
|
|
67
|
+
// Untracked paths are new files; only tracked files count as "existing".
|
|
68
|
+
const untrackedOut = await git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
69
|
+
const untracked = new Set((untrackedOut ?? "").split("\0").filter(Boolean));
|
|
70
|
+
const modifiedExistingTests = [];
|
|
71
|
+
const candidates = changedFiles.filter((path) => isTestFilePath(path) && !untracked.has(path));
|
|
72
|
+
if (candidates.length > 0) {
|
|
73
|
+
const numstat = await git(cwd, ["diff", "HEAD", "--numstat", "-z", "--", ...candidates]);
|
|
74
|
+
if (numstat !== null) {
|
|
75
|
+
// -z numstat entries: "added\tdeleted\tpath\0" (renames add a segment).
|
|
76
|
+
for (const entry of numstat.split("\0")) {
|
|
77
|
+
const parts = entry.split("\t");
|
|
78
|
+
if (parts.length < 3)
|
|
79
|
+
continue;
|
|
80
|
+
const deleted = Number(parts[1]);
|
|
81
|
+
const path = parts[2];
|
|
82
|
+
if (!path || Number.isNaN(deleted))
|
|
83
|
+
continue;
|
|
84
|
+
modifiedExistingTests.push({ path, deletedLines: deleted });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { changedFiles, modifiedExistingTests };
|
|
89
|
+
}
|
package/dist/agent/profiles.js
CHANGED
|
@@ -155,7 +155,7 @@ export function builtinAgentProfiles() {
|
|
|
155
155
|
"You are an implementer subagent working in your own isolated git worktree — the parent tree is never touched.",
|
|
156
156
|
"Your worktree forks from the last commit; changes the parent made after that commit are NOT present unless included in your briefing.",
|
|
157
157
|
"Make the smallest coherent edits that complete your assigned group. Stay strictly inside the file set you were given; if a needed change falls outside it, report that instead of editing.",
|
|
158
|
-
"Verify your changes
|
|
158
|
+
"Verify your changes when possible — run the affected module's existing tests, not only the cases you added — and end with a concise summary of files changed and verification results; the parent reviews and applies your worktree.",
|
|
159
159
|
].join("\n"),
|
|
160
160
|
},
|
|
161
161
|
];
|
package/dist/agent.d.ts
CHANGED
|
@@ -261,8 +261,10 @@ export declare class Agent {
|
|
|
261
261
|
/** Snapshot of how often each compaction path rewrote history this run. */
|
|
262
262
|
getCompactionStats(): {
|
|
263
263
|
resident: number;
|
|
264
|
+
subturn: number;
|
|
264
265
|
llm: number;
|
|
265
266
|
overflow: number;
|
|
267
|
+
fired: number;
|
|
266
268
|
droppedMessages: number;
|
|
267
269
|
};
|
|
268
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;
|
|
@@ -572,6 +573,10 @@ export class Agent {
|
|
|
572
573
|
// the redirected one.
|
|
573
574
|
hookState.exploredFiles?.clear();
|
|
574
575
|
hookState.largeTaskCheckpointDone = false;
|
|
576
|
+
// A steer is a new ask: the completion gate must be able to fire
|
|
577
|
+
// again for the follow-up's closing pass, or appended requirements
|
|
578
|
+
// never get a self-check (codeChanged stays — prior edits are real).
|
|
579
|
+
hookState.completionGateFired = false;
|
|
575
580
|
if (typeof input.content === "string" && input.content.trim()) {
|
|
576
581
|
hookState.taskType = classifyTask(input.content);
|
|
577
582
|
}
|
|
@@ -1002,6 +1007,7 @@ export class Agent {
|
|
|
1002
1007
|
const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
|
|
1003
1008
|
consecutiveOverflowRecoveries += 1;
|
|
1004
1009
|
this.compactionStats.overflow += 1;
|
|
1010
|
+
this.compactionStats.fired += 1;
|
|
1005
1011
|
this.compactionStats.droppedMessages += droppedMessages;
|
|
1006
1012
|
traceEvent("compaction_fired", { path: "overflow", droppedMessages });
|
|
1007
1013
|
yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
|
|
@@ -1520,6 +1526,7 @@ export class Agent {
|
|
|
1520
1526
|
this.lastAnchorMessageCount = null;
|
|
1521
1527
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1522
1528
|
this.compactionStats.llm += 1;
|
|
1529
|
+
this.compactionStats.fired += 1;
|
|
1523
1530
|
traceEvent("compaction_fired", { path: "llm" });
|
|
1524
1531
|
}
|
|
1525
1532
|
// If LLM compaction failed for any reason, leave this.messages alone —
|
|
@@ -2551,23 +2558,43 @@ export class Agent {
|
|
|
2551
2558
|
if (shouldAggressivelyPrune) {
|
|
2552
2559
|
candidate = aggressivePruneMessages(candidate);
|
|
2553
2560
|
}
|
|
2561
|
+
let compactedPath;
|
|
2554
2562
|
if (shouldCompact) {
|
|
2555
2563
|
const compacted = compactMessages(candidate, { keepRecentTurns });
|
|
2556
2564
|
if (compacted.compacted && compacted.messages) {
|
|
2557
2565
|
candidate = compacted.messages;
|
|
2558
|
-
|
|
2566
|
+
compactedPath = "resident";
|
|
2559
2567
|
traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
|
|
2560
2568
|
}
|
|
2569
|
+
else {
|
|
2570
|
+
// Single-instruction runs (one real user turn) never satisfy the
|
|
2571
|
+
// turn-level compactor; fold old tool groups within the turn instead.
|
|
2572
|
+
const subturn = compactCurrentTurnToolGroups(candidate, { keepRecentGroups: 3 });
|
|
2573
|
+
if (subturn.compacted && subturn.messages) {
|
|
2574
|
+
candidate = subturn.messages;
|
|
2575
|
+
compactedPath = "subturn";
|
|
2576
|
+
traceEvent("compaction_fired", { path: "subturn", droppedEntries: subturn.droppedEntries });
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
if (compactedPath)
|
|
2580
|
+
this.compactionStats.fired += 1;
|
|
2561
2581
|
}
|
|
2562
2582
|
const afterChars = estimateResidentChars(candidate);
|
|
2563
2583
|
const afterToolChars = estimateToolPayloadChars(candidate);
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2584
|
+
// Chars-not-increasing is a NECESSARY condition: the old OR gate let a
|
|
2585
|
+
// fewer-but-bigger candidate (summary stacking) rewrite history.
|
|
2586
|
+
if (afterChars <= beforeChars
|
|
2587
|
+
&& (afterChars < beforeChars
|
|
2588
|
+
|| afterToolChars < beforeToolChars
|
|
2589
|
+
|| candidate.length < before.length)) {
|
|
2567
2590
|
this.messages = candidate;
|
|
2568
2591
|
this.lastInputTokens = null;
|
|
2569
2592
|
this.lastAnchorMessageCount = null;
|
|
2570
2593
|
this.fileStateTracker?.invalidateReadHistory();
|
|
2594
|
+
if (compactedPath === "resident")
|
|
2595
|
+
this.compactionStats.resident += 1;
|
|
2596
|
+
if (compactedPath === "subturn")
|
|
2597
|
+
this.compactionStats.subturn += 1;
|
|
2571
2598
|
}
|
|
2572
2599
|
}
|
|
2573
2600
|
appendMessage(message) {
|
|
@@ -2946,7 +2973,10 @@ function estimateToolPayloadChars(messages) {
|
|
|
2946
2973
|
}, 0);
|
|
2947
2974
|
}
|
|
2948
2975
|
function countUserTurns(messages) {
|
|
2949
|
-
|
|
2976
|
+
// Real user turns only: projected reminders and summary envelopes are
|
|
2977
|
+
// user-ROLE but not user TURNS — counting them used to shrink the keep
|
|
2978
|
+
// window and fire compaction almost every turn.
|
|
2979
|
+
return messages.reduce((count, message) => count + (isRealUserMessage(message) ? 1 : 0), 0);
|
|
2950
2980
|
}
|
|
2951
2981
|
function getCurrentHeapUsed() {
|
|
2952
2982
|
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
|
}
|
package/dist/main.js
CHANGED
|
@@ -575,6 +575,26 @@ async function main() {
|
|
|
575
575
|
// mode is byte-for-byte the previous behavior.
|
|
576
576
|
const jsonOutput = args.outputFormat === "json";
|
|
577
577
|
const collector = new PrintRunCollector();
|
|
578
|
+
// Harness-side change footprint (git ground truth) — printed by the
|
|
579
|
+
// harness so the run cannot omit or misstate what it touched.
|
|
580
|
+
const changeBaseline = jsonOutput
|
|
581
|
+
? await (await import("./agent/change-tracker.js")).captureGitBaseline(args.cwd)
|
|
582
|
+
: null;
|
|
583
|
+
const collectChanges = async () => {
|
|
584
|
+
if (!changeBaseline)
|
|
585
|
+
return undefined;
|
|
586
|
+
const { detectRunChanges } = await import("./agent/change-tracker.js");
|
|
587
|
+
const detected = await detectRunChanges(args.cwd, changeBaseline).catch(() => null);
|
|
588
|
+
if (!detected)
|
|
589
|
+
return undefined;
|
|
590
|
+
return {
|
|
591
|
+
changed_files: detected.changedFiles.length,
|
|
592
|
+
modified_existing_tests: detected.modifiedExistingTests.map((t) => ({
|
|
593
|
+
path: t.path,
|
|
594
|
+
deleted_lines: t.deletedLines,
|
|
595
|
+
})),
|
|
596
|
+
};
|
|
597
|
+
};
|
|
578
598
|
const printSessionId = sessionManager
|
|
579
599
|
? basename(sessionManager.getSessionFile())
|
|
580
600
|
: undefined;
|
|
@@ -617,6 +637,7 @@ async function main() {
|
|
|
617
637
|
summary: collector.summary(),
|
|
618
638
|
sessionId: printSessionId,
|
|
619
639
|
compaction: agent.getCompactionStats(),
|
|
640
|
+
changes: await collectChanges(),
|
|
620
641
|
}) + "\n");
|
|
621
642
|
}
|
|
622
643
|
else {
|
|
@@ -633,6 +654,7 @@ async function main() {
|
|
|
633
654
|
summary: collector.summary(),
|
|
634
655
|
sessionId: printSessionId,
|
|
635
656
|
compaction: agent.getCompactionStats(),
|
|
657
|
+
changes: await collectChanges(),
|
|
636
658
|
}) + "\n");
|
|
637
659
|
process.exitCode = 1;
|
|
638
660
|
}
|
|
@@ -6,6 +6,7 @@ import { DiscoveryBarrier } from "../agent/discovery-barrier.js";
|
|
|
6
6
|
import { arbitrateToolCall } from "../agent/tool-arbiter.js";
|
|
7
7
|
import { buildCompletionSelfCheckReminder, buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
|
|
8
8
|
import { largeImplementationTaskReminder, orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
|
|
9
|
+
import { captureGitBaseline, detectRunChanges } from "../agent/change-tracker.js";
|
|
9
10
|
import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
|
|
10
11
|
import { resolve as resolvePath } from "node:path";
|
|
11
12
|
import { traceEvent } from "../debug-trace.js";
|
|
@@ -14,7 +15,13 @@ import { buildSubagentLifecycleReminder } from "../agent/subagent-lifecycle-remi
|
|
|
14
15
|
export function createDefaultHooks() {
|
|
15
16
|
return [
|
|
16
17
|
{
|
|
17
|
-
beforeTurn(ctx) {
|
|
18
|
+
async beforeTurn(ctx) {
|
|
19
|
+
// Git ground truth for the completion gate: tool-metadata bookkeeping
|
|
20
|
+
// misses bash-written files and subagent worktree merges; a dirty-state
|
|
21
|
+
// baseline at run start makes the run's real footprint observable.
|
|
22
|
+
if (ctx.state.gitBaseline === undefined) {
|
|
23
|
+
ctx.state.gitBaseline = await captureGitBaseline(ctx.cwd);
|
|
24
|
+
}
|
|
18
25
|
const taskType = classifyTask(ctx.input);
|
|
19
26
|
ctx.state.taskType = taskType;
|
|
20
27
|
ctx.state.governor = new ExecutionGovernor(taskType);
|
|
@@ -99,6 +106,7 @@ export function createDefaultHooks() {
|
|
|
99
106
|
},
|
|
100
107
|
{
|
|
101
108
|
beforeToolCall(ctx) {
|
|
109
|
+
ctx.state.toolUsed = true;
|
|
102
110
|
const arbitration = arbitrateToolCall(ctx.toolCall);
|
|
103
111
|
const toolCall = { ...arbitration.toolCall, ...(arbitration.note ? { arbiterNote: arbitration.note } : {}) };
|
|
104
112
|
ctx.replaceToolCall(toolCall);
|
|
@@ -220,7 +228,7 @@ export function createDefaultHooks() {
|
|
|
220
228
|
}
|
|
221
229
|
// Verification reminders intentionally removed. See afterToolCall.
|
|
222
230
|
},
|
|
223
|
-
afterTurn(ctx) {
|
|
231
|
+
async afterTurn(ctx) {
|
|
224
232
|
// Verification force-continuation removed. The model decides whether
|
|
225
233
|
// verification is meaningful for the task, per the system prompt.
|
|
226
234
|
//
|
|
@@ -232,21 +240,37 @@ export function createDefaultHooks() {
|
|
|
232
240
|
// "prove it" spiral that killed the old design cannot start.
|
|
233
241
|
if (ctx.state.completionGateFired)
|
|
234
242
|
return;
|
|
235
|
-
if (!ctx.state.codeChanged)
|
|
236
|
-
return;
|
|
237
243
|
// Tools are frozen: the model could not act on anything the check
|
|
238
244
|
// surfaces, so another turn is pure token burn.
|
|
239
245
|
if (ctx.state.forceTextOnlyReason)
|
|
240
246
|
return;
|
|
241
247
|
// Subagents hand off to a parent that does its own closing pass, and
|
|
242
248
|
// worktree profiles explicitly forbid the very actions a completion
|
|
243
|
-
// check would suggest.
|
|
249
|
+
// check would suggest. The parent's gate still covers their merged
|
|
250
|
+
// changes via the git baseline below.
|
|
244
251
|
if (ctx.agent.role === "subagent")
|
|
245
252
|
return;
|
|
253
|
+
// Change detection: tool metadata first (cheap), then git ground
|
|
254
|
+
// truth — which also catches bash-written files and subagent
|
|
255
|
+
// worktree merges. Gate only runs git when the run actually used
|
|
256
|
+
// tools, so external repo churn cannot hijack a pure Q&A turn.
|
|
257
|
+
let changes = null;
|
|
258
|
+
if (ctx.state.toolUsed && ctx.state.gitBaseline) {
|
|
259
|
+
changes = await detectRunChanges(ctx.cwd, ctx.state.gitBaseline);
|
|
260
|
+
}
|
|
261
|
+
const changedSomething = !!ctx.state.codeChanged || (changes !== null && changes.changedFiles.length > 0);
|
|
262
|
+
if (!changedSomething)
|
|
263
|
+
return;
|
|
246
264
|
ctx.state.completionGateFired = true;
|
|
247
265
|
ctx.state.forceContinuationReason = "completion_self_check";
|
|
248
|
-
ctx.queueReminder(buildCompletionSelfCheckReminder(
|
|
249
|
-
|
|
266
|
+
ctx.queueReminder(buildCompletionSelfCheckReminder({
|
|
267
|
+
modifiedExistingTests: changes?.modifiedExistingTests,
|
|
268
|
+
}));
|
|
269
|
+
traceEvent("completion_gate_fired", {
|
|
270
|
+
turnCount: ctx.state.turnCount,
|
|
271
|
+
changedFiles: changes?.changedFiles.length,
|
|
272
|
+
modifiedExistingTests: changes?.modifiedExistingTests.length,
|
|
273
|
+
});
|
|
250
274
|
},
|
|
251
275
|
},
|
|
252
276
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Agent } from "../agent.js";
|
|
2
2
|
import type { ContentPart, ParsedToolCall, ToolRegistryEntry, ToolResult } from "../types.js";
|
|
3
|
+
import type { GitChangeBaseline } from "../agent/change-tracker.js";
|
|
3
4
|
import type { TaskType } from "../agent/task-classifier.js";
|
|
4
5
|
import type { ExecutionGovernor } from "../agent/execution-governor.js";
|
|
5
6
|
import type { EvidenceTracker } from "../agent/evidence-tracker.js";
|
|
@@ -18,6 +19,10 @@ export interface TurnHookState {
|
|
|
18
19
|
codeChanged?: boolean;
|
|
19
20
|
/** Completion self-check gate already fired this run (fires at most once). */
|
|
20
21
|
completionGateFired?: boolean;
|
|
22
|
+
/** Git dirty-state snapshot at run start (null = not a git repo / git failed). */
|
|
23
|
+
gitBaseline?: GitChangeBaseline | null;
|
|
24
|
+
/** Any tool executed this run — guards git-change attribution for pure Q&A turns. */
|
|
25
|
+
toolUsed?: boolean;
|
|
21
26
|
smallTaskHintSent?: boolean;
|
|
22
27
|
recentEditFailures?: string[];
|
|
23
28
|
editNoMatchReminderPaths?: string[];
|
package/dist/print-output.d.ts
CHANGED
|
@@ -42,6 +42,18 @@ export declare class PrintRunCollector {
|
|
|
42
42
|
private addUsage;
|
|
43
43
|
summary(): PrintRunSummary;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Harness-observed change footprint of the run (git ground truth). Printed
|
|
47
|
+
* by the harness, not the model — a run cannot omit or misstate what it
|
|
48
|
+
* touched ("no breaking changes" next to deleted test assertions).
|
|
49
|
+
*/
|
|
50
|
+
export interface PrintChangeSummary {
|
|
51
|
+
changed_files: number;
|
|
52
|
+
modified_existing_tests: Array<{
|
|
53
|
+
path: string;
|
|
54
|
+
deleted_lines: number;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
45
57
|
/**
|
|
46
58
|
* Per-path counts of context compactions during the run. Lets benchmark
|
|
47
59
|
* harnesses correlate outcomes with "did the model ever lose sight of the
|
|
@@ -49,8 +61,11 @@ export declare class PrintRunCollector {
|
|
|
49
61
|
*/
|
|
50
62
|
export interface PrintCompactionStats {
|
|
51
63
|
resident: number;
|
|
64
|
+
subturn: number;
|
|
52
65
|
llm: number;
|
|
53
66
|
overflow: number;
|
|
67
|
+
/** Successful compaction computations incl. rejected rewrites (churn signal). */
|
|
68
|
+
fired: number;
|
|
54
69
|
droppedMessages: number;
|
|
55
70
|
}
|
|
56
71
|
export declare function formatPrintJson(input: {
|
|
@@ -58,10 +73,12 @@ export declare function formatPrintJson(input: {
|
|
|
58
73
|
sessionId?: string;
|
|
59
74
|
stopReason?: "end_turn" | "error" | "cancelled";
|
|
60
75
|
compaction?: PrintCompactionStats;
|
|
76
|
+
changes?: PrintChangeSummary;
|
|
61
77
|
}): string;
|
|
62
78
|
export declare function formatPrintJsonError(input: {
|
|
63
79
|
message: string;
|
|
64
80
|
summary?: PrintRunSummary;
|
|
65
81
|
sessionId?: string;
|
|
66
82
|
compaction?: PrintCompactionStats;
|
|
83
|
+
changes?: PrintChangeSummary;
|
|
67
84
|
}): string;
|
package/dist/print-output.js
CHANGED
|
@@ -84,6 +84,7 @@ export function formatPrintJson(input) {
|
|
|
84
84
|
usage: input.summary.usage,
|
|
85
85
|
usage_reported: input.summary.usage_reported,
|
|
86
86
|
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
87
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
export function formatPrintJsonError(input) {
|
|
@@ -101,5 +102,6 @@ export function formatPrintJsonError(input) {
|
|
|
101
102
|
}
|
|
102
103
|
: {}),
|
|
103
104
|
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
105
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
104
106
|
});
|
|
105
107
|
}
|
|
@@ -23,7 +23,14 @@ 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
|
|
26
|
+
export interface CompletionSelfCheckOptions {
|
|
27
|
+
/** Pre-existing test files this run modified, with deleted-line counts. */
|
|
28
|
+
modifiedExistingTests?: Array<{
|
|
29
|
+
path: string;
|
|
30
|
+
deletedLines: number;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export declare function buildCompletionSelfCheckReminder(options?: CompletionSelfCheckOptions): string;
|
|
27
34
|
export declare function buildToolFreezeReminder(reason: string): string;
|
|
28
35
|
export declare function buildWorkflowPhaseReminder(input: {
|
|
29
36
|
phase: "investigate" | "correlate" | "conclude";
|
package/dist/prompt/reminders.js
CHANGED
|
@@ -116,15 +116,30 @@ You have enough context to act. Do not continue reading, searching, or delegatin
|
|
|
116
116
|
Choose one of:
|
|
117
117
|
1. Use edit/write to make the requested change.
|
|
118
118
|
2. If no safe change can be made from the gathered context, explain the concrete blocker.
|
|
119
|
-
3. If files were already changed, run the
|
|
119
|
+
3. If files were already changed, verify the change and run the affected module's existing tests, or finish with the result.
|
|
120
120
|
`);
|
|
121
121
|
}
|
|
122
|
-
export function buildCompletionSelfCheckReminder() {
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
export function buildCompletionSelfCheckReminder(options = {}) {
|
|
123
|
+
const sections = [];
|
|
124
|
+
const modifiedTests = options.modifiedExistingTests ?? [];
|
|
125
|
+
if (modifiedTests.length > 0) {
|
|
126
|
+
// Disclosure, not accusation: legitimate test updates (requirement
|
|
127
|
+
// changes, fixing a broken test, refactors) are normal engineering — the
|
|
128
|
+
// rule is that they must be declared, never silent. Confrontational
|
|
129
|
+
// wording risks a model rolling back a user-requested test update.
|
|
130
|
+
const listed = modifiedTests
|
|
131
|
+
.slice(0, 8)
|
|
132
|
+
.map((t) => `- ${t.path}${t.deletedLines > 0 ? ` (${t.deletedLines} line${t.deletedLines === 1 ? "" : "s"} removed)` : ""}`)
|
|
133
|
+
.join("\n");
|
|
134
|
+
sections.push(`This run modified pre-existing test files:
|
|
135
|
+
${listed}${modifiedTests.length > 8 ? `\n- …and ${modifiedTests.length - 8} more` : ""}
|
|
136
|
+
|
|
137
|
+
If a change you made caused an existing test to fail and you edited the test so the suite passes, restore the original behavior instead — weakening a test to make it pass is never acceptable. If the test changes are intentional (the request requires new expected behavior, or the test itself was wrong), keep them and state each modified test and the reason in your final summary.`);
|
|
138
|
+
}
|
|
139
|
+
sections.push(`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
140
|
|
|
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
|
-
`);
|
|
141
|
+
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.`);
|
|
142
|
+
return wrapInSystemReminder(`\n${sections.join("\n\n")}\n`);
|
|
128
143
|
}
|
|
129
144
|
export function buildToolFreezeReminder(reason) {
|
|
130
145
|
return wrapInSystemReminder(`
|
|
@@ -24,7 +24,7 @@ Implementation workflow:
|
|
|
24
24
|
- Do not stop at a proposal when the user asked for a change.
|
|
25
25
|
- Inspect the relevant files first, then make the smallest coherent edit.
|
|
26
26
|
- Keep unrelated files and behavior out of scope.
|
|
27
|
-
-
|
|
27
|
+
- Verify the change, then run the existing tests of the affected module — not only the cases you added — or explain why they cannot be run.
|
|
28
28
|
`);
|
|
29
29
|
case "code_review":
|
|
30
30
|
return wrapInSystemReminder(`
|
|
@@ -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;
|