Skip to content

Commit 396539c

Browse files
committed
Identify compaction spacers by harness producer identity
Missing model is not a producer id (replay sanitizer fills it), and a format-category sentinel can be dropped by adapters. Stamp reserved model harness, keep a visible sentinel, and keep exhausted spacer echoes on workflow and open-task rails.
1 parent 4faae98 commit 396539c

10 files changed

Lines changed: 214 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +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 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.
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.
3637

3738
## [0.3.18] - 2026-09-08
3839

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 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:
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: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ function inferenceDone(
5757
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
5858
}
5959

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+
},
69+
usage: usage(input),
70+
source: { sourceId: "s", provider: "p", model: "m" },
71+
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
72+
}
73+
6074
function inferenceDoneWithoutUsage(): Extract<ReactorInboundEvent, { type: "inference.done" }> {
6175
return {
6276
type: "inference.done",
@@ -455,7 +469,7 @@ describe("compaction governor", () => {
455469
expect(actions?.some((a) => a.type === "compact")).toBe(true);
456470
});
457471

458-
test("consecutive threshold and idle compacts are bounded without a non-echo turn", () => {
472+
test("consecutive threshold and idle compacts are bounded until occupancy", () => {
459473
const governor = createCompactionGovernor(() => {});
460474
const echo = LEGACY_COMPACT_SPACER_TEXT;
461475
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
@@ -477,6 +491,9 @@ describe("compaction governor", () => {
477491
inferenceDone(overThreshold + 3 * resumeDelta, "real work"),
478492
tenTurns,
479493
);
494+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
495+
496+
governor.noteInferenceDone(inferenceDoneWithTools(overThreshold + 4 * resumeDelta), tenTurns);
480497
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
481498
});
482499

src/agent/compaction.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const COMPACTOR_NAME = "pruning-compactor";
2727
// would spend a reactor cycle that shrinks nothing.
2828
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
2929
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.
3035
const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2;
3136

3237
// A compact action runs in its own reactor cycle, after which the reactor
@@ -107,6 +112,9 @@ export function createCompactionGovernor(
107112
}
108113

109114
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.
110118
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) return true;
111119
return actions.some((a) => a.type === "reply" && assistantTextIsCompactSpacerEcho(a.content));
112120
}
@@ -116,7 +124,7 @@ export function createCompactionGovernor(
116124
turns: readonly ConversationTurn[],
117125
): void {
118126
overflowRecoveries = 0;
119-
if (!isCompactSpacerEchoTurn(event.turn)) {
127+
if (event.turn.content.some((block) => block.type === "tool_call")) {
120128
consecutiveThresholdCompacts = 0;
121129
}
122130
if (requestContinuation === undefined) return;
@@ -133,6 +141,7 @@ export function createCompactionGovernor(
133141
}
134142
if (contextTokens <= compactionThresholdFor(lastModel)) {
135143
tokensAtLastCompact = undefined;
144+
consecutiveThresholdCompacts = 0;
136145
}
137146
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
138147
// pending left from an earlier over-threshold turn (e.g. after the

src/agent/director.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,13 @@ function inferWithNudge(
7676
//
7777
// Assumes a terminal bare wait always means the turn is over. That holds for
7878
// every current wait path: DefaultDirector in conversational mode (the only
79-
// mode ChatDirector uses) yields one only on an empty model turn, and its halt
80-
// path already carries a reply; the compaction, workflow, and open-task
81-
// rewrites either keep those terminals or replace them with an infer.
82-
// A future wait that pauses mid-turn while expecting more work must not be
83-
// 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.
8486
function ensureCycleSettlesWithReply(
8587
actions: ReactorAction | ReactorAction[],
8688
capabilities: ReactorCapabilities,
@@ -720,7 +722,12 @@ class ChatDirectorImpl extends DefaultDirector {
720722
if (hasToolCalls) {
721723
this.workflowIdleTurns = 0;
722724
} else {
723-
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++;
724731
}
725732
}
726733
for (const block of event.turn.content) {
@@ -810,11 +817,15 @@ class ChatDirectorImpl extends DefaultDirector {
810817
this.spacerEchoNudges++;
811818
return inferWithNudge(capabilities, SPACER_ECHO_NUDGE);
812819
}
813-
return capabilities.wait();
814820
}
815821

816822
const base = await super.decide(event, state, capabilities);
817-
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+
}
818829

819830
this.compaction.noteIdleTurn(event, baseActions);
820831
const compacted = this.compaction.interceptActions(event, baseActions, capabilities);
@@ -834,12 +845,11 @@ class ChatDirectorImpl extends DefaultDirector {
834845

835846
const coordinator = this.workflowCoordinator;
836847
if (coordinator?.isActive() && !coordinator.currentStepIsGate()) {
837-
const actions = Array.isArray(base) ? base : [base];
838-
const hasTerminal = actions.some((a) => a.type === "wait" || a.type === "reply");
839-
if (hasTerminal && this.lastInferenceTurnHadContent) {
848+
const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply");
849+
if (hasTerminal && (this.lastInferenceTurnHadContent || spacerEchoExhausted)) {
840850
if (this.operatorJustResponded) {
841851
this.operatorJustResponded = false;
842-
return base;
852+
return baseActions;
843853
}
844854
if (this.workflowIdleTurns >= 3) {
845855
if (hasActiveTasks(this.tasks)) this.logTerminationWithOpenTasks("workflow-idle-stall");
@@ -858,7 +868,7 @@ class ChatDirectorImpl extends DefaultDirector {
858868
`\n\nYou have not yet completed this workflow step. ` +
859869
`If this step is complete, ${stepClause}. ` +
860870
`Otherwise continue working with tools.`;
861-
const passThrough = actions.filter(
871+
const passThrough = baseActions.filter(
862872
(a): a is Exclude<ReactorAction, { type: "wait" } | { type: "reply" }> =>
863873
a.type !== "wait" && a.type !== "reply",
864874
);
@@ -890,7 +900,7 @@ class ChatDirectorImpl extends DefaultDirector {
890900
}
891901
}
892902

893-
return base;
903+
return baseActions;
894904
}
895905
}
896906

src/context-compactor.test.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
COMPACTED_PREFIX,
1010
COMPACT_SPACER_TEXT,
1111
LEGACY_COMPACT_SPACER_TEXT,
12+
HARNESS_COMPACT_SPACER_MODEL,
1213
isHarnessCompactSpacer,
1314
type SessionMetadata,
1415
} from "./session/compactor.js";
@@ -629,13 +630,13 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
629630
).toBe(true);
630631
});
631632

632-
test("harness spacer has no model and uses the non-lexical sentinel", async () => {
633+
test("harness spacer is stamped with the reserved producer id and a visible sentinel", async () => {
633634
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
634635
const output1 = (await compactor.apply(grow([], 16, "round1"), mockStrategyCtx)).output;
635636
const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;
636637
const spacer = output2.find(isHarnessCompactSpacer);
637638
expect(spacer).toBeDefined();
638-
expect(spacer?.model).toBeUndefined();
639+
expect(spacer!.model).toBe(HARNESS_COMPACT_SPACER_MODEL);
639640
expect(firstText(spacer!)).toBe(COMPACT_SPACER_TEXT);
640641
expect(firstText(spacer!)).not.toBe(LEGACY_COMPACT_SPACER_TEXT);
641642
expect(COMPACT_SPACER_TEXT).not.toBe(LEGACY_COMPACT_SPACER_TEXT);
@@ -665,11 +666,40 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
665666
expect(output2.slice(0, frozenLen)).not.toContain(echo);
666667
expect(isHarnessCompactSpacer(echo)).toBe(false);
667668
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);
669+
expect(harness).toBeDefined();
670+
expect(harness!.model).toBe(HARNESS_COMPACT_SPACER_MODEL);
671+
expect(firstText(harness!)).toBe(COMPACT_SPACER_TEXT);
672+
expect(harness).not.toBe(echo);
673+
});
674+
675+
test("frozen prefix does not absorb a model-stamped new-sentinel echo", 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 echo = makeTurn({
681+
role: "assistant",
682+
model: "omen-alpha",
683+
content: [{ type: "text", text: COMPACT_SPACER_TEXT }],
684+
});
685+
const output2 = (await compactor.apply(grow([summary!, echo], 16, "round2"), mockStrategyCtx))
686+
.output;
687+
688+
let frozenLen = 0;
689+
while (
690+
frozenLen < output2.length &&
691+
firstText(output2[frozenLen]!).startsWith(COMPACTED_PREFIX)
692+
) {
693+
frozenLen++;
694+
if (frozenLen < output2.length && isHarnessCompactSpacer(output2[frozenLen]!)) frozenLen++;
672695
}
696+
expect(output2.slice(0, frozenLen)).not.toContain(echo);
697+
expect(isHarnessCompactSpacer(echo)).toBe(false);
698+
const harness = output2.find(isHarnessCompactSpacer);
699+
expect(harness).toBeDefined();
700+
expect(harness!.model).toBe(HARNESS_COMPACT_SPACER_MODEL);
701+
expect(firstText(harness!)).toBe(COMPACT_SPACER_TEXT);
702+
expect(harness).not.toBe(echo);
673703
});
674704

675705
test("legacy harness spacer without model still freezes", async () => {

src/director.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,4 +1177,51 @@ describe("chatDirector spacer echo", () => {
11771177
);
11781178
expect(afterReset.some((a) => a.type === "infer")).toBe(true);
11791179
});
1180+
1181+
test("after echo-cap with open tasks, falls through to open-task rails", async () => {
1182+
const director = createChatDirector("base", [], { onTasksChange: () => {} });
1183+
await director.decide(
1184+
makeInferenceDoneEvent([
1185+
{
1186+
id: "mt",
1187+
name: "manage_tasks",
1188+
args: { action: "create", tasks: [{ id: "t1", title: "work", status: "doing" }] },
1189+
},
1190+
]),
1191+
mockState,
1192+
mockCapabilities,
1193+
);
1194+
for (let i = 0; i < 2; i++) {
1195+
const nudged = actionsArray(
1196+
await director.decide(
1197+
spacerInferenceDone(LEGACY_COMPACT_SPACER_TEXT),
1198+
mockState,
1199+
mockCapabilities,
1200+
),
1201+
);
1202+
expect(nudged.some((a) => a.type === "infer")).toBe(true);
1203+
}
1204+
const afterCap = actionsArray(
1205+
await director.decide(spacerInferenceDone(COMPACT_SPACER_TEXT), mockState, mockCapabilities),
1206+
);
1207+
expect(afterCap.some((a) => a.type === "infer")).toBe(true);
1208+
expect(afterCap.some((a) => a.type === "reply" && "content" in a && a.content === "")).toBe(
1209+
false,
1210+
);
1211+
for (let i = 0; i < 2; i++) {
1212+
const nudged = actionsArray(
1213+
await director.decide(
1214+
spacerInferenceDone(COMPACT_SPACER_TEXT),
1215+
mockState,
1216+
mockCapabilities,
1217+
),
1218+
);
1219+
expect(nudged.some((a) => a.type === "infer")).toBe(true);
1220+
}
1221+
const exhausted = actionsArray(
1222+
await director.decide(spacerInferenceDone(COMPACT_SPACER_TEXT), mockState, mockCapabilities),
1223+
);
1224+
expect(exhausted.some((a) => a.type === "infer")).toBe(false);
1225+
expect(exhausted.some((a) => a.type === "reply")).toBe(true);
1226+
});
11801227
});

src/provider/replay-sanitizer.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import {
1414
THINKING_ONLY_OMITTED,
1515
withReplaySanitizer,
1616
} from "./replay-sanitizer.js";
17+
import {
18+
COMPACT_SPACER_TEXT,
19+
COMPACTED_PREFIX,
20+
HARNESS_COMPACT_SPACER_MODEL,
21+
isHarnessCompactSpacer,
22+
} from "../session/compactor.js";
1723

1824
const GROK_SIGNATURE = "grok-opaque-signature-blob";
1925

@@ -400,4 +406,32 @@ describe("withReplaySanitizer", () => {
400406
expect(body.input.some((item) => item.type === "reasoning")).toBe(true);
401407
expect(body.input.some((item) => item.type === "function_call")).toBe(true);
402408
});
409+
410+
it("keeps a harness compact spacer as a role-alternating assistant turn", () => {
411+
const spacer: ConversationTurn = {
412+
role: "assistant",
413+
model: HARNESS_COMPACT_SPACER_MODEL,
414+
content: [{ type: "text", text: COMPACT_SPACER_TEXT }],
415+
timestamp: 2,
416+
};
417+
const turns = sanitizeReplayTurns(
418+
[
419+
{
420+
role: "user",
421+
content: [{ type: "text", text: `${COMPACTED_PREFIX} earlier` }],
422+
timestamp: 1,
423+
},
424+
spacer,
425+
{
426+
role: "user",
427+
content: [{ type: "text", text: `${COMPACTED_PREFIX} later` }],
428+
timestamp: 3,
429+
},
430+
],
431+
"claude-opus-4",
432+
);
433+
expect(turns.map((t) => t.role)).toEqual(["user", "assistant", "user"]);
434+
expect(turns[1]?.content).toEqual([{ type: "text", text: COMPACT_SPACER_TEXT }]);
435+
expect(isHarnessCompactSpacer(turns[1]!)).toBe(true);
436+
});
403437
});

0 commit comments

Comments
 (0)