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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
closures count against `maxAnchorTurns`. The LLM summary is workflow-aware
and skips degenerate assistant text.

- **Prefix-stable summaries and growth hysteresis.** Existing compacted user
turns stay byte-identical across later passes; new folds become later summary
turns with an assistant spacer so the prompt prefix can stay in the KV cache.
After a compact that remains over the high watermark, the governor waits for
usage to grow by 10% of the window before re-arming. Overflow recovery still
compacts immediately.

### Plugins

- **`run_shell` no longer defaults to a 15s timeout.** Omitted timeout arms no
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,11 @@ 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 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 an assistant spacer between them so the prompt head can remain in the provider KV cache. 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.
- **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.
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever.
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself.

The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor, after emitting `compact`, self-delivers a content-less inbound message (a host-supplied `requestContinuation` callback). That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history.

Expand Down
59 changes: 58 additions & 1 deletion src/agent/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
TokenUsage,
} from "@intx/types/runtime";
import { createCompactionGovernor } from "./compaction.js";
import { compactionThresholdFor } from "../provider/context-window.js";
import { compactionResumeDeltaFor, compactionThresholdFor } from "../provider/context-window.js";
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";

const capabilities = {
Expand Down Expand Up @@ -86,6 +86,7 @@ function overflowError(): ReactorInboundEvent {
}

const overThreshold = compactionThresholdFor("m") + 1;
const resumeDelta = compactionResumeDeltaFor("m");
const inferAction: ReactorAction[] = [{ type: "infer" }];
const tenTurns = turnsOfLength(10, 1);
const threeTurns = turnsOfLength(3, 1);
Expand Down Expand Up @@ -335,4 +336,60 @@ describe("compaction governor", () => {
// arming decision trusted reported usage, so it is not re-checked here.
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
});

test("does not re-arm after a compact that remains over the high watermark", () => {
const governor = createCompactionGovernor(() => {});
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

// Post-compact snapshot is still over high; growth hysteresis must hold
// the next arm until usage grows by resumeDelta.
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
});

test("re-arms after usage grows by the resume delta past the last compact", () => {
const governor = createCompactionGovernor(() => {});
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

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

governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta), tenTurns);
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
expect(actions).not.toBeNull();
expect(actions?.some((a) => a.type === "compact")).toBe(true);
});

test("clears hysteresis once usage drops under the high watermark", () => {
const governor = createCompactionGovernor(() => {});
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

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

governor.noteInferenceDone(inferenceDone(1000), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();

// Next crossing of high arms immediately — no growth delta required.
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
expect(actions).not.toBeNull();
expect(actions?.some((a) => a.type === "compact")).toBe(true);
});

test("overflow still compact while hysteresis blocks the proactive path", () => {
const governor = createCompactionGovernor(() => {});
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();

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

const actions = governor.interceptOverflow(overflowError(), capabilities);
expect(actions).not.toBeNull();
expect(actions?.some((a) => a.type === "compact")).toBe(true);
});
});
36 changes: 34 additions & 2 deletions src/agent/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type {
ReactorInboundEvent,
ToolDefinition,
} from "@intx/types/runtime";
import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js";
import {
compactionResumeDeltaFor,
compactionThresholdFor,
contextTokensFromUsage,
} from "../provider/context-window.js";
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
import { onTurnBoundary } from "./reactor-events.js";
Expand Down Expand Up @@ -46,6 +50,12 @@ export function createCompactionGovernor(
// inference cycles (see interceptActions) where the event carries no model.
let lastModel: string | undefined;
let turnCount = 0;
// Growth hysteresis after a compact that remained over the high watermark:
// snapshot the post-compact infer's usage, then do not re-arm until usage
// grows by resumeDelta. Cleared once usage drops back to or under high.
// Overflow recovery ignores this and arms regardless.
let tokensAtLastCompact: number | undefined;
let awaitingPostCompactMeasurement = false;

// Running local estimate of the turns we send, plus the fixed system-prompt
// and tool-schema overhead every request carries. Providers that omit usage
Expand All @@ -63,7 +73,17 @@ export function createCompactionGovernor(
}

function isOverThreshold(contextTokens: number): boolean {
return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT;
if (turnCount <= MIN_TURNS_TO_COMPACT) return false;
const high = compactionThresholdFor(lastModel);
if (contextTokens <= high) return false;
if (tokensAtLastCompact !== undefined) {
return contextTokens >= tokensAtLastCompact + compactionResumeDeltaFor(lastModel);
}
return true;
}

function noteCompactIssued(): void {
awaitingPostCompactMeasurement = true;
}

function noteInferenceDone(
Expand All @@ -77,6 +97,15 @@ export function createCompactionGovernor(
const reportedTokens = contextTokensFromUsage(event.usage);
usingEstimate = reportedTokens <= 0;
const contextTokens = usingEstimate ? estimate.tokens : reportedTokens;
// Snapshot on the first inference.done after a compact (the post-compact
// infer), not at intercept time — intercept has no fresh usage.
if (awaitingPostCompactMeasurement) {
tokensAtLastCompact = contextTokens;
awaitingPostCompactMeasurement = false;
}
if (contextTokens <= compactionThresholdFor(lastModel)) {
tokensAtLastCompact = undefined;
}
// 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
// provider reports real usage that lands below the threshold).
Expand Down Expand Up @@ -107,6 +136,7 @@ export function createCompactionGovernor(
if (!actions.some((a) => a.type === "infer")) return null;
pending = false;
postCompactInfer = true;
noteCompactIssued();
requestContinuation?.();
return [
...actions.filter((a) => a.type !== "infer"),
Expand Down Expand Up @@ -143,6 +173,7 @@ export function createCompactionGovernor(
postCompactInfer = true;
requestContinuation?.();
}
noteCompactIssued();
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
}

Expand All @@ -161,6 +192,7 @@ export function createCompactionGovernor(
overflowRecoveries++;
pending = false;
postCompactInfer = true;
noteCompactIssued();
requestContinuation();
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
}
Expand Down
108 changes: 107 additions & 1 deletion src/context-compactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import {
formatPlan,
classifyTaskBoundary,
buildLLMTurnSummary,
COMPACTED_PREFIX,
COMPACT_SPACER_TEXT,
type SessionMetadata,
} from "./session/compactor.js";
import type { ConversationTurn, ReactorState, StrategyContext } from "@intx/types/runtime";
import { createModelSummarizer } from "./session/summarizer.js";
import type {
ConversationTurn,
InferenceSource,
ReactorState,
StrategyContext,
} from "@intx/types/runtime";

const mockStrategyCtx: StrategyContext = {
state: {} as ReactorState,
Expand Down Expand Up @@ -577,6 +585,104 @@ describe("createPruningCompactor — summarize receives the workflow context (CL
});
});

describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
function firstText(turn: ConversationTurn): string {
const block = turn.content.find((b) => b.type === "text");
return block !== undefined && block.type === "text" ? block.text : "";
}

function compactedTurns(output: ConversationTurn[]): ConversationTurn[] {
return output.filter((t) => firstText(t).startsWith(COMPACTED_PREFIX));
}

function grow(base: ConversationTurn[], count: number, label: string): ConversationTurn[] {
const extra: ConversationTurn[] = [];
for (let i = 0; i < count; i++) {
extra.push(
makeTurn({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `${label} ${i}` }],
}),
);
}
return [...base, ...extra];
}

test("second apply leaves output[0] bytes identical and appends a later summary", async () => {
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
const turns = grow([], 16, "round1");
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
expect(firstText(output1[0]!)).toContain(COMPACTED_PREFIX);

const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;

expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
expect(output2[0]).toBe(output1[0]);
const summaries = compactedTurns(output2);
expect(summaries.length).toBeGreaterThanOrEqual(2);
expect(output2.indexOf(summaries[1]!)).toBeGreaterThan(0);
expect(hasConsecutiveSameRole(output2)).toBe(false);
expect(
output2.some((t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT),
).toBe(true);
});

test("empty-fold keep-set returns the input unchanged", async () => {
const compactor = createPruningCompactor({
keepRecentTurns: 1,
maxAnchorTurns: 8,
summaryMaxChars: 500,
});
const turns: ConversationTurn[] = [
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
makeTurn({
role: "assistant",
content: [
{ type: "tool_call", id: "c1", name: "edit_file", arguments: { path: "src/a.ts" } },
],
}),
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
];
const result = await compactor.apply(turns, mockStrategyCtx);
expect(result.output).toBe(turns);
expect(result.record.reason).toBe("no compaction needed");
});

test("failing then succeeding summarizer does not rewrite output[0]", async () => {
const source: InferenceSource = {
id: "test",
provider: "openai",
model: "test-model",
baseURL: "http://localhost:1",
apiKey: "k",
};
let calls = 0;
const summarize = createModelSummarizer({
getSource: () => source,
complete: async () => {
calls++;
if (calls === 1) throw new Error("model unreachable");
return "UNIQUE_SUCCESS_SUMMARY";
},
});
const compactor = createPruningCompactor({
keepRecentTurns: 2,
summaryMaxChars: 500,
summarize,
});
const turns = grow([], 16, "fail");
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
expect(firstText(output1[0]!)).toContain("Turns compacted:");
expect(firstText(output1[0]!)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
expect(firstText(output1[0]!)).toContain("Model summary unavailable");

const output2 = (await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)).output;
expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY");
expect(hasConsecutiveSameRole(output2)).toBe(false);
});
});

describe("buildContextEnvelope", () => {
test("includes active task label", () => {
const result = buildContextEnvelope({
Expand Down
11 changes: 11 additions & 0 deletions src/provider/context-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export function contextWindowFor(model: string): number {
// warning threshold so the color shift matches when compaction starts.
export const COMPACTION_WINDOW_FRACTION = 0.6;

// After a compact that remains over the high watermark, the governor waits for
// usage to grow by this fraction of the window before re-arming. Growth
// hysteresis, not a low watermark: dropping under 60% is not required.
export const COMPACTION_RESUME_FRACTION = 0.1;

// Status-bar meter turns danger at this fraction of the window — past
// compaction and approaching hard overflow at 1.0. Inclusive integer bands
// keep 80 in warning and start danger at 81.
Expand All @@ -102,3 +107,9 @@ export function compactionThresholdFor(model: string | undefined): number {
const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW;
return Math.floor(window * COMPACTION_WINDOW_FRACTION);
}

/** Tokens of growth past the last post-compact measurement before re-arming. */
export function compactionResumeDeltaFor(model: string | undefined): number {
const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW;
return Math.floor(window * COMPACTION_RESUME_FRACTION);
}
Loading
Loading