Skip to content

Commit f425e3f

Browse files
Merge pull request #851 from corbitsdev/cl-7539-stop-compaction-spacer-text-from-becoming-a-model-echo-loop
Stop compaction spacer text from becoming a model echo loop
2 parents 11288c3 + 396539c commit f425e3f

10 files changed

Lines changed: 515 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ 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 stamp a reserved harness producer id and a visible
34+
sentinel. Spacer-only replies are incomplete and stay on open-task and
35+
workflow rails. Frozen-prefix matching ignores model-emitted copies of the
36+
marker.
3337

3438
## [0.3.18] - 2026-09-08
3539

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 assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. 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: 76 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,30 @@ 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+
},
55+
usage: usage(input),
56+
source: { sourceId: "s", provider: "p", model: "m" },
57+
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
58+
}
59+
60+
function inferenceDoneWithTools(
61+
input: number,
62+
): Extract<ReactorInboundEvent, { type: "inference.done" }> {
63+
return {
64+
type: "inference.done",
65+
turn: {
66+
role: "assistant",
67+
content: [{ type: "tool_call", id: "c1", name: "read_file", arguments: { path: "a.ts" } }],
68+
},
4469
usage: usage(input),
4570
source: { sourceId: "s", provider: "p", model: "m" },
4671
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
@@ -443,4 +468,52 @@ describe("compaction governor", () => {
443468
expect(actions).not.toBeNull();
444469
expect(actions?.some((a) => a.type === "compact")).toBe(true);
445470
});
471+
472+
test("consecutive threshold and idle compacts are bounded until occupancy", () => {
473+
const governor = createCompactionGovernor(() => {});
474+
const echo = LEGACY_COMPACT_SPACER_TEXT;
475+
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
476+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
477+
478+
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
479+
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
480+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
481+
482+
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
483+
governor.noteInferenceDone(inferenceDone(overThreshold + 2 * resumeDelta, echo), tenTurns);
484+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
485+
governor.noteIdleTurn(inferenceDone(overThreshold + 2 * resumeDelta, echo), [
486+
{ type: "reply", content: "done" },
487+
]);
488+
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
489+
490+
governor.noteInferenceDone(
491+
inferenceDone(overThreshold + 3 * resumeDelta, "real work"),
492+
tenTurns,
493+
);
494+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
495+
496+
governor.noteInferenceDone(inferenceDoneWithTools(overThreshold + 4 * resumeDelta), tenTurns);
497+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
498+
});
499+
500+
test("spacer-echo terminal does not arm idle compact", () => {
501+
let continuations = 0;
502+
const governor = createCompactionGovernor(() => continuations++);
503+
governor.noteInferenceDone(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), tenTurns);
504+
governor.noteIdleTurn(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), [
505+
{ type: "reply", content: LEGACY_COMPACT_SPACER_TEXT },
506+
]);
507+
expect(continuations).toBe(0);
508+
governor.noteIdleTurn(inferenceDone(overThreshold, COMPACT_SPACER_TEXT), [
509+
{ type: "reply", content: COMPACT_SPACER_TEXT },
510+
]);
511+
expect(continuations).toBe(0);
512+
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
513+
514+
governor.noteIdleTurn(inferenceDone(overThreshold, "done"), [
515+
{ type: "reply", content: "done" },
516+
]);
517+
expect(continuations).toBe(1);
518+
});
446519
});

src/agent/compaction.ts

Lines changed: 43 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,12 @@ 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+
// Last-ditch bound on compact→infer→compact when the post-compact infer never
31+
// occupies the loop. Reset on tool-call occupancy or when a post-compact
32+
// measurement lands at or under the high watermark (that infer is not itself
33+
// a compact). Do not reset merely because assistant text ≠ spacer. Overflow
34+
// recoveries (above) reset on any successful inference.done instead.
35+
const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2;
2536

2637
// A compact action runs in its own reactor cycle, after which the reactor
2738
// idles until the next inbound event. Worker loops (sub-agents, the coding
@@ -45,6 +56,7 @@ export function createCompactionGovernor(
4556
// operator question to answer). Distinct from postCompactInfer.
4657
let postCompactMeter = false;
4758
let overflowRecoveries = 0;
59+
let consecutiveThresholdCompacts = 0;
4860
// Set whenever the arming decision fell back to the local estimate because
4961
// the provider omitted usage or reported zero, so callers rendering a meter
5062
// can flag the number as approximate instead of implying provider-grade
@@ -90,11 +102,31 @@ export function createCompactionGovernor(
90102
awaitingPostCompactMeasurement = true;
91103
}
92104

105+
function atThresholdCompactCap(): boolean {
106+
return consecutiveThresholdCompacts >= MAX_CONSECUTIVE_THRESHOLD_COMPACTS;
107+
}
108+
109+
function issueThresholdCompact(): void {
110+
consecutiveThresholdCompacts++;
111+
noteCompactIssued();
112+
}
113+
114+
function isSpacerEchoTerminal(event: ReactorInboundEvent, actions: ReactorAction[]): boolean {
115+
// Fail-closed only. ChatDirector owns spacer-echo completeness (nudge, then
116+
// loop-protection / workflow / open-task rails). This just refuses to treat
117+
// that incomplete wait or reply as an idle-compact pause.
118+
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) return true;
119+
return actions.some((a) => a.type === "reply" && assistantTextIsCompactSpacerEcho(a.content));
120+
}
121+
93122
function noteInferenceDone(
94123
event: Extract<ReactorInboundEvent, { type: "inference.done" }>,
95124
turns: readonly ConversationTurn[],
96125
): void {
97126
overflowRecoveries = 0;
127+
if (event.turn.content.some((block) => block.type === "tool_call")) {
128+
consecutiveThresholdCompacts = 0;
129+
}
98130
if (requestContinuation === undefined) return;
99131
syncFromTurns(turns);
100132
lastModel = event.source?.model;
@@ -109,6 +141,7 @@ export function createCompactionGovernor(
109141
}
110142
if (contextTokens <= compactionThresholdFor(lastModel)) {
111143
tokensAtLastCompact = undefined;
144+
consecutiveThresholdCompacts = 0;
112145
}
113146
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
114147
// pending left from an earlier over-threshold turn (e.g. after the
@@ -138,9 +171,10 @@ export function createCompactionGovernor(
138171
if (event.type !== "tool.done") return null;
139172
if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) return null;
140173
if (!actions.some((a) => a.type === "infer")) return null;
174+
if (atThresholdCompactCap()) return null;
141175
pending = false;
142176
postCompactInfer = true;
143-
noteCompactIssued();
177+
issueThresholdCompact();
144178
requestContinuation?.();
145179
return [
146180
...actions.filter((a) => a.type !== "infer"),
@@ -154,7 +188,9 @@ export function createCompactionGovernor(
154188
// compact when it (or the operator's next message) arrives.
155189
function noteIdleTurn(event: ReactorInboundEvent, actions: ReactorAction[]): void {
156190
if (!pending || idlePending || requestContinuation === undefined) return;
191+
if (atThresholdCompactCap()) return;
157192
if (!onTurnBoundary(event)) return;
193+
if (isSpacerEchoTerminal(event, actions)) return;
158194
const terminal =
159195
actions.some((a) => a.type === "reply" || a.type === "wait") &&
160196
!actions.some((a) => a.type === "infer" || a.type === "execute_tools");
@@ -168,6 +204,10 @@ export function createCompactionGovernor(
168204
capabilities: ReactorCapabilities,
169205
): ReactorAction[] | null {
170206
if (!idlePending || event.type !== "message.received") return null;
207+
if (atThresholdCompactCap()) {
208+
idlePending = false;
209+
return null;
210+
}
171211
idlePending = false;
172212
pending = false;
173213
const content = typeof event.message.content === "string" ? event.message.content : "";
@@ -180,7 +220,7 @@ export function createCompactionGovernor(
180220
} else {
181221
postCompactMeter = true;
182222
}
183-
noteCompactIssued();
223+
issueThresholdCompact();
184224
requestContinuation?.();
185225
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
186226
}

src/agent/director.ts

Lines changed: 45 additions & 17 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";
@@ -72,11 +76,13 @@ function inferWithNudge(
7276
//
7377
// Assumes a terminal bare wait always means the turn is over. That holds for
7478
// every current wait path: DefaultDirector in conversational mode (the only
75-
// mode ChatDirector uses) yields one only on an empty model turn, and its halt
76-
// path already carries a reply; the compaction, workflow, and open-task
77-
// rewrites either keep those terminals or replace them with an infer.
78-
// A future wait that pauses mid-turn while expecting more work must not be
79-
// settled here.
79+
// mode ChatDirector uses) yields one only on an empty model turn; exhausted
80+
// spacer-echo incompleteness uses the same wait so loop-protection, workflow,
81+
// and open-task rails can rewrite it to infer first. This helper only settles
82+
// a leftover wait into an empty reply. The halt path already carries a reply;
83+
// compaction, workflow, and open-task rewrites either keep those terminals or
84+
// replace them with an infer. A future wait that pauses mid-turn while
85+
// expecting more work must not be settled here.
8086
function ensureCycleSettlesWithReply(
8187
actions: ReactorAction | ReactorAction[],
8288
capabilities: ReactorCapabilities,
@@ -98,6 +104,9 @@ function ensureCycleSettlesWithReply(
98104
const MAX_OPEN_TASK_NUDGES = 3;
99105
const MAX_DECLINED_OPEN_TASK_NUDGES = 2;
100106
const MAX_INFERENCE_RECOVERIES = 2;
107+
const MAX_SPACER_ECHO_NUDGES = 2;
108+
109+
const SPACER_ECHO_NUDGE = "Continue the task. Do not repeat internal markers.";
101110

102111
const IDLE_OPEN_TASK_NUDGE =
103112
"\n\nYou are ending your turn while tasks are still open (todo/doing). " +
@@ -394,6 +403,7 @@ class ChatDirectorImpl extends DefaultDirector {
394403
private idleTerminationNudges = 0;
395404
private declinedTerminationNudges = 0;
396405
private inferenceRecoveries = 0;
406+
private spacerEchoNudges = 0;
397407
private lastInferenceTurnHadContent = false;
398408
private operatorJustResponded = false;
399409
private tasks: Task[] = [];
@@ -633,6 +643,7 @@ class ChatDirectorImpl extends DefaultDirector {
633643
this.idleTerminationNudges = 0;
634644
this.declinedTerminationNudges = 0;
635645
this.inferenceRecoveries = 0;
646+
this.spacerEchoNudges = 0;
636647
this.toolOnlyStreak = 0;
637648
this.toolOnlyNudgeFired = false;
638649
this.pendingToolOnlyNudge = false;
@@ -682,9 +693,10 @@ class ChatDirectorImpl extends DefaultDirector {
682693
if (onTurnBoundary(event)) {
683694
this.turnCount++;
684695
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-
);
696+
const hasText =
697+
event.turn.content.some(
698+
(b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0,
699+
) && !isCompactSpacerEchoTurn(event.turn);
688700
this.lastInferenceTurnHadContent = hasToolCalls || hasText;
689701

690702
// toolOnlyStreak is narration-sensitive: any turn with text clears it
@@ -710,7 +722,12 @@ class ChatDirectorImpl extends DefaultDirector {
710722
if (hasToolCalls) {
711723
this.workflowIdleTurns = 0;
712724
} else {
713-
this.workflowIdleTurns++;
725+
// Echo-nudge cycles are incompleteness, not a contentful idle beat.
726+
// Count them only after the echo budget is spent so the step-nudge
727+
// rail still has its three turns before the stuck reply.
728+
const spacerEchoStillNudging =
729+
isCompactSpacerEchoTurn(event.turn) && this.spacerEchoNudges < MAX_SPACER_ECHO_NUDGES;
730+
if (!spacerEchoStillNudging) this.workflowIdleTurns++;
714731
}
715732
}
716733
for (const block of event.turn.content) {
@@ -795,8 +812,20 @@ class ChatDirectorImpl extends DefaultDirector {
795812
this.compaction.noteInferenceDone(event, turns);
796813
}
797814

815+
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) {
816+
if (this.spacerEchoNudges < MAX_SPACER_ECHO_NUDGES) {
817+
this.spacerEchoNudges++;
818+
return inferWithNudge(capabilities, SPACER_ECHO_NUDGE);
819+
}
820+
}
821+
798822
const base = await super.decide(event, state, capabilities);
799-
const baseActions = Array.isArray(base) ? base : [base];
823+
let baseActions = Array.isArray(base) ? base : [base];
824+
const spacerEchoExhausted =
825+
event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn);
826+
if (spacerEchoExhausted) {
827+
baseActions = baseActions.map((a) => (a.type === "reply" ? capabilities.wait() : a));
828+
}
800829

801830
this.compaction.noteIdleTurn(event, baseActions);
802831
const compacted = this.compaction.interceptActions(event, baseActions, capabilities);
@@ -816,12 +845,11 @@ class ChatDirectorImpl extends DefaultDirector {
816845

817846
const coordinator = this.workflowCoordinator;
818847
if (coordinator?.isActive() && !coordinator.currentStepIsGate()) {
819-
const actions = Array.isArray(base) ? base : [base];
820-
const hasTerminal = actions.some((a) => a.type === "wait" || a.type === "reply");
821-
if (hasTerminal && this.lastInferenceTurnHadContent) {
848+
const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply");
849+
if (hasTerminal && (this.lastInferenceTurnHadContent || spacerEchoExhausted)) {
822850
if (this.operatorJustResponded) {
823851
this.operatorJustResponded = false;
824-
return base;
852+
return baseActions;
825853
}
826854
if (this.workflowIdleTurns >= 3) {
827855
if (hasActiveTasks(this.tasks)) this.logTerminationWithOpenTasks("workflow-idle-stall");
@@ -840,7 +868,7 @@ class ChatDirectorImpl extends DefaultDirector {
840868
`\n\nYou have not yet completed this workflow step. ` +
841869
`If this step is complete, ${stepClause}. ` +
842870
`Otherwise continue working with tools.`;
843-
const passThrough = actions.filter(
871+
const passThrough = baseActions.filter(
844872
(a): a is Exclude<ReactorAction, { type: "wait" } | { type: "reply" }> =>
845873
a.type !== "wait" && a.type !== "reply",
846874
);
@@ -872,7 +900,7 @@ class ChatDirectorImpl extends DefaultDirector {
872900
}
873901
}
874902

875-
return base;
903+
return baseActions;
876904
}
877905
}
878906

0 commit comments

Comments
 (0)