Skip to content

Commit 4faae98

Browse files
committed
Stop compaction spacer text from becoming a model echo loop
Harness spacers were a natural-language token the model could copy as a finished turn. Frozen-prefix matching then treated the echo as harness-inserted, so idle compact re-armed and the prefix grew.
1 parent 11288c3 commit 4faae98

8 files changed

Lines changed: 323 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3030
impersonating Reject; Escape still denies.
3131
- After context compaction, ChatGPT Codex requests keep the operating prompt as
3232
instructions.
33+
- Compaction spacers are a non-lexical harness sentinel rather than
34+
model-echoable prose. Spacer-only replies are treated as incomplete.
35+
Frozen-prefix matching ignores model-emitted copies of the marker.
3336

3437
## [0.3.18] - 2026-09-08
3538

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
162162

163163
#### Context compaction (the compaction governor)
164164

165-
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with an assistant spacer between them so the prompt head can remain in the provider KV cache. The governor covers three cases:
165+
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted, non-lexical assistant spacer (identified by a missing `model` field plus the sentinel, including a legacy `[compaction]` token) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. The governor covers three cases:
166166

167167
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor uses **growth hysteresis** (wait for usage to grow by ~10% of the window) instead of re-arming on every cycle; dropping under 60% is not required.
168168
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.

src/agent/compaction.test.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import type {
88
} from "@intx/types/runtime";
99
import { createCompactionGovernor } from "./compaction.js";
1010
import { compactionResumeDeltaFor, compactionThresholdFor } from "../provider/context-window.js";
11-
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
11+
import {
12+
COMPACTOR_KEEP_RECENT_TURNS,
13+
COMPACT_SPACER_TEXT,
14+
LEGACY_COMPACT_SPACER_TEXT,
15+
compactorNoOpFloor,
16+
} from "../session/compactor.js";
1217

1318
const capabilities = {
1419
infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }),
@@ -37,10 +42,16 @@ function turnsOfLength(count: number, textLength: number): ConversationTurn[] {
3742
})) as unknown as ConversationTurn[];
3843
}
3944

40-
function inferenceDone(input: number): Extract<ReactorInboundEvent, { type: "inference.done" }> {
45+
function inferenceDone(
46+
input: number,
47+
text = "",
48+
): Extract<ReactorInboundEvent, { type: "inference.done" }> {
4149
return {
4250
type: "inference.done",
43-
turn: { role: "assistant", content: [] },
51+
turn: {
52+
role: "assistant",
53+
content: text.length > 0 ? [{ type: "text", text }] : [],
54+
},
4455
usage: usage(input),
4556
source: { sourceId: "s", provider: "p", model: "m" },
4657
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
@@ -443,4 +454,49 @@ describe("compaction governor", () => {
443454
expect(actions).not.toBeNull();
444455
expect(actions?.some((a) => a.type === "compact")).toBe(true);
445456
});
457+
458+
test("consecutive threshold and idle compacts are bounded without a non-echo turn", () => {
459+
const governor = createCompactionGovernor(() => {});
460+
const echo = LEGACY_COMPACT_SPACER_TEXT;
461+
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
462+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
463+
464+
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
465+
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
466+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
467+
468+
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
469+
governor.noteInferenceDone(inferenceDone(overThreshold + 2 * resumeDelta, echo), tenTurns);
470+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
471+
governor.noteIdleTurn(inferenceDone(overThreshold + 2 * resumeDelta, echo), [
472+
{ type: "reply", content: "done" },
473+
]);
474+
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
475+
476+
governor.noteInferenceDone(
477+
inferenceDone(overThreshold + 3 * resumeDelta, "real work"),
478+
tenTurns,
479+
);
480+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
481+
});
482+
483+
test("spacer-echo terminal does not arm idle compact", () => {
484+
let continuations = 0;
485+
const governor = createCompactionGovernor(() => continuations++);
486+
governor.noteInferenceDone(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), tenTurns);
487+
governor.noteIdleTurn(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), [
488+
{ type: "reply", content: LEGACY_COMPACT_SPACER_TEXT },
489+
]);
490+
expect(continuations).toBe(0);
491+
governor.noteIdleTurn(inferenceDone(overThreshold, COMPACT_SPACER_TEXT), [
492+
{ type: "reply", content: COMPACT_SPACER_TEXT },
493+
]);
494+
expect(continuations).toBe(0);
495+
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
496+
497+
governor.noteIdleTurn(inferenceDone(overThreshold, "done"), [
498+
{ type: "reply", content: "done" },
499+
]);
500+
expect(continuations).toBe(1);
501+
});
446502
});

src/agent/compaction.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
compactionThresholdFor,
1111
contextTokensFromUsage,
1212
} from "../provider/context-window.js";
13-
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
13+
import {
14+
COMPACTOR_KEEP_RECENT_TURNS,
15+
assistantTextIsCompactSpacerEcho,
16+
compactorNoOpFloor,
17+
isCompactSpacerEchoTurn,
18+
} from "../session/compactor.js";
1419
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
1520
import { onTurnBoundary } from "./reactor-events.js";
1621

@@ -22,6 +27,7 @@ const COMPACTOR_NAME = "pruning-compactor";
2227
// would spend a reactor cycle that shrinks nothing.
2328
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
2429
const MAX_OVERFLOW_RECOVERIES = 2;
30+
const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2;
2531

2632
// A compact action runs in its own reactor cycle, after which the reactor
2733
// idles until the next inbound event. Worker loops (sub-agents, the coding
@@ -45,6 +51,7 @@ export function createCompactionGovernor(
4551
// operator question to answer). Distinct from postCompactInfer.
4652
let postCompactMeter = false;
4753
let overflowRecoveries = 0;
54+
let consecutiveThresholdCompacts = 0;
4855
// Set whenever the arming decision fell back to the local estimate because
4956
// the provider omitted usage or reported zero, so callers rendering a meter
5057
// can flag the number as approximate instead of implying provider-grade
@@ -90,11 +97,28 @@ export function createCompactionGovernor(
9097
awaitingPostCompactMeasurement = true;
9198
}
9299

100+
function atThresholdCompactCap(): boolean {
101+
return consecutiveThresholdCompacts >= MAX_CONSECUTIVE_THRESHOLD_COMPACTS;
102+
}
103+
104+
function issueThresholdCompact(): void {
105+
consecutiveThresholdCompacts++;
106+
noteCompactIssued();
107+
}
108+
109+
function isSpacerEchoTerminal(event: ReactorInboundEvent, actions: ReactorAction[]): boolean {
110+
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) return true;
111+
return actions.some((a) => a.type === "reply" && assistantTextIsCompactSpacerEcho(a.content));
112+
}
113+
93114
function noteInferenceDone(
94115
event: Extract<ReactorInboundEvent, { type: "inference.done" }>,
95116
turns: readonly ConversationTurn[],
96117
): void {
97118
overflowRecoveries = 0;
119+
if (!isCompactSpacerEchoTurn(event.turn)) {
120+
consecutiveThresholdCompacts = 0;
121+
}
98122
if (requestContinuation === undefined) return;
99123
syncFromTurns(turns);
100124
lastModel = event.source?.model;
@@ -138,9 +162,10 @@ export function createCompactionGovernor(
138162
if (event.type !== "tool.done") return null;
139163
if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) return null;
140164
if (!actions.some((a) => a.type === "infer")) return null;
165+
if (atThresholdCompactCap()) return null;
141166
pending = false;
142167
postCompactInfer = true;
143-
noteCompactIssued();
168+
issueThresholdCompact();
144169
requestContinuation?.();
145170
return [
146171
...actions.filter((a) => a.type !== "infer"),
@@ -154,7 +179,9 @@ export function createCompactionGovernor(
154179
// compact when it (or the operator's next message) arrives.
155180
function noteIdleTurn(event: ReactorInboundEvent, actions: ReactorAction[]): void {
156181
if (!pending || idlePending || requestContinuation === undefined) return;
182+
if (atThresholdCompactCap()) return;
157183
if (!onTurnBoundary(event)) return;
184+
if (isSpacerEchoTerminal(event, actions)) return;
158185
const terminal =
159186
actions.some((a) => a.type === "reply" || a.type === "wait") &&
160187
!actions.some((a) => a.type === "infer" || a.type === "execute_tools");
@@ -168,6 +195,10 @@ export function createCompactionGovernor(
168195
capabilities: ReactorCapabilities,
169196
): ReactorAction[] | null {
170197
if (!idlePending || event.type !== "message.received") return null;
198+
if (atThresholdCompactCap()) {
199+
idlePending = false;
200+
return null;
201+
}
171202
idlePending = false;
172203
pending = false;
173204
const content = typeof event.message.content === "string" ? event.message.content : "";
@@ -180,7 +211,7 @@ export function createCompactionGovernor(
180211
} else {
181212
postCompactMeter = true;
182213
}
183-
noteCompactIssued();
214+
issueThresholdCompact();
184215
requestContinuation?.();
185216
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
186217
}

src/agent/director.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import type {
1010
ConversationTurn,
1111
RetryPolicy,
1212
} from "@intx/types/runtime";
13-
import { type SessionMetadata, type TaskBoundary } from "../session/compactor.js";
13+
import {
14+
type SessionMetadata,
15+
type TaskBoundary,
16+
isCompactSpacerEchoTurn,
17+
} from "../session/compactor.js";
1418
import type { WorkflowCoordinator } from "../workflows/coordinator.js";
1519
import { createCompactionGovernor, type CompactionGovernor } from "./compaction.js";
1620
import { onTurnBoundary } from "./reactor-events.js";
@@ -98,6 +102,9 @@ function ensureCycleSettlesWithReply(
98102
const MAX_OPEN_TASK_NUDGES = 3;
99103
const MAX_DECLINED_OPEN_TASK_NUDGES = 2;
100104
const MAX_INFERENCE_RECOVERIES = 2;
105+
const MAX_SPACER_ECHO_NUDGES = 2;
106+
107+
const SPACER_ECHO_NUDGE = "Continue the task. Do not repeat internal markers.";
101108

102109
const IDLE_OPEN_TASK_NUDGE =
103110
"\n\nYou are ending your turn while tasks are still open (todo/doing). " +
@@ -394,6 +401,7 @@ class ChatDirectorImpl extends DefaultDirector {
394401
private idleTerminationNudges = 0;
395402
private declinedTerminationNudges = 0;
396403
private inferenceRecoveries = 0;
404+
private spacerEchoNudges = 0;
397405
private lastInferenceTurnHadContent = false;
398406
private operatorJustResponded = false;
399407
private tasks: Task[] = [];
@@ -633,6 +641,7 @@ class ChatDirectorImpl extends DefaultDirector {
633641
this.idleTerminationNudges = 0;
634642
this.declinedTerminationNudges = 0;
635643
this.inferenceRecoveries = 0;
644+
this.spacerEchoNudges = 0;
636645
this.toolOnlyStreak = 0;
637646
this.toolOnlyNudgeFired = false;
638647
this.pendingToolOnlyNudge = false;
@@ -682,9 +691,10 @@ class ChatDirectorImpl extends DefaultDirector {
682691
if (onTurnBoundary(event)) {
683692
this.turnCount++;
684693
const hasToolCalls = event.turn.content.some((b) => b.type === "tool_call");
685-
const hasText = event.turn.content.some(
686-
(b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0,
687-
);
694+
const hasText =
695+
event.turn.content.some(
696+
(b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0,
697+
) && !isCompactSpacerEchoTurn(event.turn);
688698
this.lastInferenceTurnHadContent = hasToolCalls || hasText;
689699

690700
// toolOnlyStreak is narration-sensitive: any turn with text clears it
@@ -795,6 +805,14 @@ class ChatDirectorImpl extends DefaultDirector {
795805
this.compaction.noteInferenceDone(event, turns);
796806
}
797807

808+
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) {
809+
if (this.spacerEchoNudges < MAX_SPACER_ECHO_NUDGES) {
810+
this.spacerEchoNudges++;
811+
return inferWithNudge(capabilities, SPACER_ECHO_NUDGE);
812+
}
813+
return capabilities.wait();
814+
}
815+
798816
const base = await super.decide(event, state, capabilities);
799817
const baseActions = Array.isArray(base) ? base : [base];
800818

src/context-compactor.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
buildLLMTurnSummary,
99
COMPACTED_PREFIX,
1010
COMPACT_SPACER_TEXT,
11+
LEGACY_COMPACT_SPACER_TEXT,
12+
isHarnessCompactSpacer,
1113
type SessionMetadata,
1214
} from "./session/compactor.js";
1315
import { createModelSummarizer } from "./session/summarizer.js";
@@ -627,6 +629,65 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
627629
).toBe(true);
628630
});
629631

632+
test("harness spacer has no model and uses the non-lexical sentinel", async () => {
633+
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
634+
const output1 = (await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)).output;
635+
const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;
636+
const spacer = output2.find(isHarnessCompactSpacer);
637+
expect(spacer).toBeDefined();
638+
expect(spacer?.model).toBeUndefined();
639+
expect(firstText(spacer!)).toBe(COMPACT_SPACER_TEXT);
640+
expect(firstText(spacer!)).not.toBe(LEGACY_COMPACT_SPACER_TEXT);
641+
expect(COMPACT_SPACER_TEXT).not.toBe(LEGACY_COMPACT_SPACER_TEXT);
642+
});
643+
644+
test("frozen prefix does not absorb a model-emitted spacer", async () => {
645+
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
646+
const output1 = (await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)).output;
647+
const summary = output1.find((t) => firstText(t).startsWith(COMPACTED_PREFIX));
648+
expect(summary).toBeDefined();
649+
const echo = makeTurn({
650+
role: "assistant",
651+
model: "omen-alpha",
652+
content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }],
653+
});
654+
const output2 = (await compactor.apply(grow([summary!, echo], 16, "round2"), mockStrategyCtx))
655+
.output;
656+
657+
let frozenLen = 0;
658+
while (
659+
frozenLen < output2.length &&
660+
firstText(output2[frozenLen]!).startsWith(COMPACTED_PREFIX)
661+
) {
662+
frozenLen++;
663+
if (frozenLen < output2.length && isHarnessCompactSpacer(output2[frozenLen]!)) frozenLen++;
664+
}
665+
expect(output2.slice(0, frozenLen)).not.toContain(echo);
666+
expect(isHarnessCompactSpacer(echo)).toBe(false);
667+
const harness = output2.find(isHarnessCompactSpacer);
668+
if (harness !== undefined) {
669+
expect(harness.model).toBeUndefined();
670+
expect(firstText(harness)).toBe(COMPACT_SPACER_TEXT);
671+
expect(harness).not.toBe(echo);
672+
}
673+
});
674+
675+
test("legacy harness spacer without model still freezes", async () => {
676+
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
677+
const output1 = (await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)).output;
678+
const summary = output1.find((t) => firstText(t).startsWith(COMPACTED_PREFIX));
679+
expect(summary).toBeDefined();
680+
const legacySpacer = makeTurn({
681+
role: "assistant",
682+
content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }],
683+
});
684+
const grown = grow([summary!, legacySpacer], 16, "round2");
685+
const output2 = (await compactor.apply(grown, mockStrategyCtx)).output;
686+
expect(output2[0]).toBe(summary);
687+
expect(output2[1]).toBe(legacySpacer);
688+
expect(isHarnessCompactSpacer(legacySpacer)).toBe(true);
689+
});
690+
630691
test("empty-fold keep-set returns the input unchanged", async () => {
631692
const compactor = createPruningCompactor({
632693
keepRecentTurns: 1,

0 commit comments

Comments
 (0)