Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
impersonating Reject; Escape still denies.
- After context compaction, ChatGPT Codex requests keep the operating prompt as
instructions.
- Compaction spacers stamp a reserved harness producer id and a visible
sentinel. Spacer-only replies are incomplete and stay on open-task and
workflow rails. Frozen-prefix matching ignores model-emitted copies of the
marker.

## [0.3.18] - 2026-09-08

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h

#### Context compaction (the compaction governor)

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:
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:

- **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.
- **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.
Expand Down
79 changes: 76 additions & 3 deletions src/agent/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type {
} from "@intx/types/runtime";
import { createCompactionGovernor } from "./compaction.js";
import { compactionResumeDeltaFor, compactionThresholdFor } from "../provider/context-window.js";
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
import {
COMPACTOR_KEEP_RECENT_TURNS,
COMPACT_SPACER_TEXT,
LEGACY_COMPACT_SPACER_TEXT,
compactorNoOpFloor,
} from "../session/compactor.js";

const capabilities = {
infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }),
Expand Down Expand Up @@ -37,10 +42,30 @@ function turnsOfLength(count: number, textLength: number): ConversationTurn[] {
})) as unknown as ConversationTurn[];
}

function inferenceDone(input: number): Extract<ReactorInboundEvent, { type: "inference.done" }> {
function inferenceDone(
input: number,
text = "",
): Extract<ReactorInboundEvent, { type: "inference.done" }> {
return {
type: "inference.done",
turn: { role: "assistant", content: [] },
turn: {
role: "assistant",
content: text.length > 0 ? [{ type: "text", text }] : [],
},
usage: usage(input),
source: { sourceId: "s", provider: "p", model: "m" },
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
}

function inferenceDoneWithTools(
input: number,
): Extract<ReactorInboundEvent, { type: "inference.done" }> {
return {
type: "inference.done",
turn: {
role: "assistant",
content: [{ type: "tool_call", id: "c1", name: "read_file", arguments: { path: "a.ts" } }],
},
usage: usage(input),
source: { sourceId: "s", provider: "p", model: "m" },
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
Expand Down Expand Up @@ -443,4 +468,52 @@ describe("compaction governor", () => {
expect(actions).not.toBeNull();
expect(actions?.some((a) => a.type === "compact")).toBe(true);
});

test("consecutive threshold and idle compacts are bounded until occupancy", () => {
const governor = createCompactionGovernor(() => {});
const echo = LEGACY_COMPACT_SPACER_TEXT;
governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns);
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta, echo), tenTurns);
governor.noteInferenceDone(inferenceDone(overThreshold + 2 * resumeDelta, echo), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
governor.noteIdleTurn(inferenceDone(overThreshold + 2 * resumeDelta, echo), [
{ type: "reply", content: "done" },
]);
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();

governor.noteInferenceDone(
inferenceDone(overThreshold + 3 * resumeDelta, "real work"),
tenTurns,
);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();

governor.noteInferenceDone(inferenceDoneWithTools(overThreshold + 4 * resumeDelta), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
});

test("spacer-echo terminal does not arm idle compact", () => {
let continuations = 0;
const governor = createCompactionGovernor(() => continuations++);
governor.noteInferenceDone(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), tenTurns);
governor.noteIdleTurn(inferenceDone(overThreshold, LEGACY_COMPACT_SPACER_TEXT), [
{ type: "reply", content: LEGACY_COMPACT_SPACER_TEXT },
]);
expect(continuations).toBe(0);
governor.noteIdleTurn(inferenceDone(overThreshold, COMPACT_SPACER_TEXT), [
{ type: "reply", content: COMPACT_SPACER_TEXT },
]);
expect(continuations).toBe(0);
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();

governor.noteIdleTurn(inferenceDone(overThreshold, "done"), [
{ type: "reply", content: "done" },
]);
expect(continuations).toBe(1);
});
});
46 changes: 43 additions & 3 deletions src/agent/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
compactionThresholdFor,
contextTokensFromUsage,
} from "../provider/context-window.js";
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
import {
COMPACTOR_KEEP_RECENT_TURNS,
assistantTextIsCompactSpacerEcho,
compactorNoOpFloor,
isCompactSpacerEchoTurn,
} from "../session/compactor.js";
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
import { onTurnBoundary } from "./reactor-events.js";

Expand All @@ -22,6 +27,12 @@ const COMPACTOR_NAME = "pruning-compactor";
// would spend a reactor cycle that shrinks nothing.
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
const MAX_OVERFLOW_RECOVERIES = 2;
// Last-ditch bound on compact→infer→compact when the post-compact infer never
// occupies the loop. Reset on tool-call occupancy or when a post-compact
// measurement lands at or under the high watermark (that infer is not itself
// a compact). Do not reset merely because assistant text ≠ spacer. Overflow
// recoveries (above) reset on any successful inference.done instead.
const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2;

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

function atThresholdCompactCap(): boolean {
return consecutiveThresholdCompacts >= MAX_CONSECUTIVE_THRESHOLD_COMPACTS;
}

function issueThresholdCompact(): void {
consecutiveThresholdCompacts++;
noteCompactIssued();
}

function isSpacerEchoTerminal(event: ReactorInboundEvent, actions: ReactorAction[]): boolean {
// Fail-closed only. ChatDirector owns spacer-echo completeness (nudge, then
// loop-protection / workflow / open-task rails). This just refuses to treat
// that incomplete wait or reply as an idle-compact pause.
if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) return true;
return actions.some((a) => a.type === "reply" && assistantTextIsCompactSpacerEcho(a.content));
}

function noteInferenceDone(
event: Extract<ReactorInboundEvent, { type: "inference.done" }>,
turns: readonly ConversationTurn[],
): void {
overflowRecoveries = 0;
if (event.turn.content.some((block) => block.type === "tool_call")) {
consecutiveThresholdCompacts = 0;
}
if (requestContinuation === undefined) return;
syncFromTurns(turns);
lastModel = event.source?.model;
Expand All @@ -109,6 +141,7 @@ export function createCompactionGovernor(
}
if (contextTokens <= compactionThresholdFor(lastModel)) {
tokensAtLastCompact = undefined;
consecutiveThresholdCompacts = 0;
}
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
// pending left from an earlier over-threshold turn (e.g. after the
Expand Down Expand Up @@ -138,9 +171,10 @@ export function createCompactionGovernor(
if (event.type !== "tool.done") return null;
if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) return null;
if (!actions.some((a) => a.type === "infer")) return null;
if (atThresholdCompactCap()) return null;
pending = false;
postCompactInfer = true;
noteCompactIssued();
issueThresholdCompact();
requestContinuation?.();
return [
...actions.filter((a) => a.type !== "infer"),
Expand All @@ -154,7 +188,9 @@ export function createCompactionGovernor(
// compact when it (or the operator's next message) arrives.
function noteIdleTurn(event: ReactorInboundEvent, actions: ReactorAction[]): void {
if (!pending || idlePending || requestContinuation === undefined) return;
if (atThresholdCompactCap()) return;
if (!onTurnBoundary(event)) return;
if (isSpacerEchoTerminal(event, actions)) return;
const terminal =
actions.some((a) => a.type === "reply" || a.type === "wait") &&
!actions.some((a) => a.type === "infer" || a.type === "execute_tools");
Expand All @@ -168,6 +204,10 @@ export function createCompactionGovernor(
capabilities: ReactorCapabilities,
): ReactorAction[] | null {
if (!idlePending || event.type !== "message.received") return null;
if (atThresholdCompactCap()) {
idlePending = false;
return null;
}
idlePending = false;
pending = false;
const content = typeof event.message.content === "string" ? event.message.content : "";
Expand All @@ -180,7 +220,7 @@ export function createCompactionGovernor(
} else {
postCompactMeter = true;
}
noteCompactIssued();
issueThresholdCompact();
requestContinuation?.();
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
}
Expand Down
62 changes: 45 additions & 17 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import type {
ConversationTurn,
RetryPolicy,
} from "@intx/types/runtime";
import { type SessionMetadata, type TaskBoundary } from "../session/compactor.js";
import {
type SessionMetadata,
type TaskBoundary,
isCompactSpacerEchoTurn,
} from "../session/compactor.js";
import type { WorkflowCoordinator } from "../workflows/coordinator.js";
import { createCompactionGovernor, type CompactionGovernor } from "./compaction.js";
import { onTurnBoundary } from "./reactor-events.js";
Expand Down Expand Up @@ -72,11 +76,13 @@ function inferWithNudge(
//
// Assumes a terminal bare wait always means the turn is over. That holds for
// every current wait path: DefaultDirector in conversational mode (the only
// mode ChatDirector uses) yields one only on an empty model turn, and its halt
// path already carries a reply; the compaction, workflow, and open-task
// rewrites either keep those terminals or replace them with an infer.
// A future wait that pauses mid-turn while expecting more work must not be
// settled here.
// mode ChatDirector uses) yields one only on an empty model turn; exhausted
// spacer-echo incompleteness uses the same wait so loop-protection, workflow,
// and open-task rails can rewrite it to infer first. This helper only settles
// a leftover wait into an empty reply. The halt path already carries a reply;
// compaction, workflow, and open-task rewrites either keep those terminals or
// replace them with an infer. A future wait that pauses mid-turn while
// expecting more work must not be settled here.
function ensureCycleSettlesWithReply(
actions: ReactorAction | ReactorAction[],
capabilities: ReactorCapabilities,
Expand All @@ -98,6 +104,9 @@ function ensureCycleSettlesWithReply(
const MAX_OPEN_TASK_NUDGES = 3;
const MAX_DECLINED_OPEN_TASK_NUDGES = 2;
const MAX_INFERENCE_RECOVERIES = 2;
const MAX_SPACER_ECHO_NUDGES = 2;

const SPACER_ECHO_NUDGE = "Continue the task. Do not repeat internal markers.";

const IDLE_OPEN_TASK_NUDGE =
"\n\nYou are ending your turn while tasks are still open (todo/doing). " +
Expand Down Expand Up @@ -394,6 +403,7 @@ class ChatDirectorImpl extends DefaultDirector {
private idleTerminationNudges = 0;
private declinedTerminationNudges = 0;
private inferenceRecoveries = 0;
private spacerEchoNudges = 0;
private lastInferenceTurnHadContent = false;
private operatorJustResponded = false;
private tasks: Task[] = [];
Expand Down Expand Up @@ -633,6 +643,7 @@ class ChatDirectorImpl extends DefaultDirector {
this.idleTerminationNudges = 0;
this.declinedTerminationNudges = 0;
this.inferenceRecoveries = 0;
this.spacerEchoNudges = 0;
this.toolOnlyStreak = 0;
this.toolOnlyNudgeFired = false;
this.pendingToolOnlyNudge = false;
Expand Down Expand Up @@ -682,9 +693,10 @@ class ChatDirectorImpl extends DefaultDirector {
if (onTurnBoundary(event)) {
this.turnCount++;
const hasToolCalls = event.turn.content.some((b) => b.type === "tool_call");
const hasText = event.turn.content.some(
(b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0,
);
const hasText =
event.turn.content.some(
(b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0,
) && !isCompactSpacerEchoTurn(event.turn);
this.lastInferenceTurnHadContent = hasToolCalls || hasText;

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

if (event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn)) {
if (this.spacerEchoNudges < MAX_SPACER_ECHO_NUDGES) {
this.spacerEchoNudges++;
return inferWithNudge(capabilities, SPACER_ECHO_NUDGE);
}
}

const base = await super.decide(event, state, capabilities);
const baseActions = Array.isArray(base) ? base : [base];
let baseActions = Array.isArray(base) ? base : [base];
const spacerEchoExhausted =
event.type === "inference.done" && isCompactSpacerEchoTurn(event.turn);
if (spacerEchoExhausted) {
baseActions = baseActions.map((a) => (a.type === "reply" ? capabilities.wait() : a));
}

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

const coordinator = this.workflowCoordinator;
if (coordinator?.isActive() && !coordinator.currentStepIsGate()) {
const actions = Array.isArray(base) ? base : [base];
const hasTerminal = actions.some((a) => a.type === "wait" || a.type === "reply");
if (hasTerminal && this.lastInferenceTurnHadContent) {
const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply");
if (hasTerminal && (this.lastInferenceTurnHadContent || spacerEchoExhausted)) {
if (this.operatorJustResponded) {
this.operatorJustResponded = false;
return base;
return baseActions;
}
if (this.workflowIdleTurns >= 3) {
if (hasActiveTasks(this.tasks)) this.logTerminationWithOpenTasks("workflow-idle-stall");
Expand All @@ -840,7 +868,7 @@ class ChatDirectorImpl extends DefaultDirector {
`\n\nYou have not yet completed this workflow step. ` +
`If this step is complete, ${stepClause}. ` +
`Otherwise continue working with tools.`;
const passThrough = actions.filter(
const passThrough = baseActions.filter(
(a): a is Exclude<ReactorAction, { type: "wait" } | { type: "reply" }> =>
a.type !== "wait" && a.type !== "reply",
);
Expand Down Expand Up @@ -872,7 +900,7 @@ class ChatDirectorImpl extends DefaultDirector {
}
}

return base;
return baseActions;
}
}

Expand Down
Loading
Loading