Skip to content

Commit d098a62

Browse files
committed
fix(compaction): fold fat anchors into the handoff not the live prompt
Unexcerpted edit_file pairs were riding the live fold as score-10 anchors and holding occupancy over the 60% trigger. The live set is now the thin spine plus a token-capped tail: file-edit bodies go in the fat handoff, images count against the tail budget, superseded reads still stub when the tail is the whole transcript, and a still- over fold notifies the operator without weakening the occupancy latch.
1 parent d757830 commit d098a62

12 files changed

Lines changed: 505 additions & 318 deletions

‎src/agent/chat-event-subscribers.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import {
33
CHAT_TASKS_CHANGED_EVENT,
44
CHAT_TOOLS_ACTIVATE_EVENT,
5+
COMPACTION_FOLD_NONCONVERGED_EVENT,
56
} from "./director.js";
67
import { handleChatDirectorEvent } from "./chat-event-subscribers.js";
78
import type { Task } from "./tasks.js";
@@ -119,4 +120,28 @@ describe("handleChatDirectorEvent", () => {
119120
expect(dispatched).toBe(false);
120121
expect(log.calls).toEqual([]);
121122
});
123+
124+
test("dispatches a fold-nonconverged notice", () => {
125+
const seen: string[] = [];
126+
const log = makeLog();
127+
const handled = handleChatDirectorEvent(
128+
{
129+
type: COMPACTION_FOLD_NONCONVERGED_EVENT,
130+
data: { notice: "Context fold did not reduce occupancy." },
131+
},
132+
{
133+
onTasksChanged: () => {
134+
throw new Error("unexpected tasks-changed dispatch");
135+
},
136+
onToolsActivate: () => {
137+
throw new Error("unexpected tools-activate dispatch");
138+
},
139+
onFoldNonConverged: (notice) => seen.push(notice),
140+
},
141+
log.log,
142+
);
143+
expect(handled).toBe(true);
144+
expect(seen).toEqual(["Context fold did not reduce occupancy."]);
145+
expect(log.calls).toEqual([]);
146+
});
122147
});

‎src/agent/chat-event-subscribers.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import { type } from "arktype";
1010
import {
1111
CHAT_TASKS_CHANGED_EVENT,
1212
CHAT_TOOLS_ACTIVATE_EVENT,
13+
COMPACTION_FOLD_NONCONVERGED_EVENT,
1314
ChatTasksChangedDataSchema,
1415
ChatToolsActivateDataSchema,
16+
CompactionFoldNonConvergedDataSchema,
1517
} from "./director.js";
1618
import type { Task } from "./tasks.js";
1719

1820
export interface ChatDirectorEventHandlers {
1921
onTasksChanged: (tasks: Task[]) => void;
2022
onToolsActivate: (names: string[]) => void;
23+
onFoldNonConverged?: (notice: string) => void;
2124
}
2225

2326
export type ChatDirectorEventDebugLog = (
@@ -58,5 +61,17 @@ export function handleChatDirectorEvent(
5861
handlers.onToolsActivate(parsed.names);
5962
return true;
6063
}
64+
if (event.type === COMPACTION_FOLD_NONCONVERGED_EVENT) {
65+
const parsed = CompactionFoldNonConvergedDataSchema(event.data);
66+
if (parsed instanceof type.errors) {
67+
logDebug(
68+
"chat fold-nonconverged event dropped invalid payload: {error}",
69+
{ error: parsed.summary },
70+
);
71+
return true;
72+
}
73+
handlers.onFoldNonConverged?.(parsed.notice);
74+
return true;
75+
}
6176
return false;
6277
}

‎src/agent/compaction.test.ts‎

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ import {
1010
COMPACTION_CONTINUATION_EVENT,
1111
OPERATOR_COMPACT_REASON,
1212
createCompactionGovernor,
13+
foldNonConvergedNotice,
1314
stickyExtraInstructionsFromRecords,
1415
} from "./compaction.js";
16+
import {
17+
COMPACTION_FOLD_NONCONVERGED_EVENT,
18+
createChatDirector,
19+
} from "./director.js";
1520
import {
1621
compactionWideResumeDeltaFor,
1722
compactionThresholdFor,
@@ -157,7 +162,10 @@ describe("compaction governor", () => {
157162
governor.interceptActions(toolDone(), inferAction, capabilities),
158163
).toBeNull();
159164

160-
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(1, 1));
165+
governor.noteInferenceDone(
166+
inferenceDone(overThreshold),
167+
turnsOfLength(1, 1),
168+
);
161169
expect(
162170
governor.interceptActions(toolDone(), inferAction, capabilities),
163171
).toBeNull();
@@ -705,7 +713,10 @@ describe("compaction governor", () => {
705713

706714
test("manual compact no-ops below the compactor floor", () => {
707715
const governor = createCompactionGovernor(undefined);
708-
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(1, 1));
716+
governor.noteInferenceDone(
717+
inferenceDone(overThreshold),
718+
turnsOfLength(1, 1),
719+
);
709720
expect(governor.requestManual("focus on tests")).toBe("noop");
710721
expect(governor.extraInstructions).toBeUndefined();
711722
expect(
@@ -844,6 +855,103 @@ describe("post-compact above-threshold latch (CL-9006)", () => {
844855
expect(governor.foldNonConverged).toBe(true);
845856
});
846857

858+
test("foldNonConvergedNotice names the still-over fold for the operator", () => {
859+
expect(foldNonConvergedNotice()).toMatch(/did not reduce occupancy/i);
860+
expect(foldNonConvergedNotice()).toMatch(/paused/i);
861+
});
862+
863+
test("ChatDirector emits the non-convergence notice once after a still-over fold", async () => {
864+
const director = createChatDirector("sys", [], {});
865+
const caps: ReactorCapabilities = {
866+
infer: () => ({ type: "infer" }),
867+
executeTools: () => ({ type: "execute_tools", calls: [] }),
868+
suspend: (gate) => ({ type: "suspend", gate }),
869+
fork: (mode, forkId) => ({ type: "fork", mode, forkId }),
870+
emit: (eventType, data) => ({ type: "emit", eventType, data }),
871+
reply: (content: string) => ({ type: "reply", content }),
872+
checkpoint: (message = "") => ({ type: "checkpoint", message }),
873+
compact: (compactor, reason) => ({ type: "compact", compactor, reason }),
874+
wait: () => ({ type: "wait" }),
875+
done: () => ({ type: "done" }),
876+
};
877+
const manyTurns = {
878+
turns: Array.from({ length: 8 }, () => ({
879+
role: "assistant" as const,
880+
content: [],
881+
model: "test-model",
882+
timestamp: 0,
883+
})),
884+
activeForks: [],
885+
pendingOperations: [],
886+
activeGates: [],
887+
tokenUsage: usage(overThreshold),
888+
lastCycleUsage: null,
889+
lastCycleSource: null,
890+
sessionId: "test-session",
891+
};
892+
const done = (input: number): ReactorInboundEvent =>
893+
({
894+
type: "inference.done",
895+
turn: {
896+
role: "assistant",
897+
content: [
898+
{
899+
type: "tool_call",
900+
id: "call-1",
901+
name: "read_file",
902+
arguments: { path: "x.ts" },
903+
},
904+
],
905+
model: "test-model",
906+
timestamp: 0,
907+
},
908+
usage: usage(input),
909+
source: {
910+
sourceId: "test",
911+
provider: "openai",
912+
model: "test-model",
913+
},
914+
}) as ReactorInboundEvent;
915+
const toolDoneEvent = {
916+
type: "tool.done",
917+
result: { callId: "call-1", content: "ok", isError: false },
918+
} as ReactorInboundEvent;
919+
920+
await director.decide(done(overThreshold), manyTurns as never, caps);
921+
await director.decide(toolDoneEvent, manyTurns as never, caps);
922+
const post = await director.decide(
923+
done(overThreshold),
924+
manyTurns as never,
925+
caps,
926+
);
927+
const arr = Array.isArray(post) ? post : [post];
928+
const notices = arr.filter(
929+
(a) =>
930+
a.type === "emit" &&
931+
"eventType" in a &&
932+
a.eventType === COMPACTION_FOLD_NONCONVERGED_EVENT,
933+
);
934+
expect(notices).toHaveLength(1);
935+
expect(notices[0]).toMatchObject({
936+
data: { notice: foldNonConvergedNotice() },
937+
});
938+
939+
const again = await director.decide(
940+
done(overThreshold + 1),
941+
manyTurns as never,
942+
caps,
943+
);
944+
const againArr = Array.isArray(again) ? again : [again];
945+
expect(
946+
againArr.filter(
947+
(a) =>
948+
a.type === "emit" &&
949+
"eventType" in a &&
950+
a.eventType === COMPACTION_FOLD_NONCONVERGED_EVENT,
951+
),
952+
).toHaveLength(0);
953+
});
954+
847955
test("the consecutive-compact cap holds across tool-call occupancy", () => {
848956
const governor = createCompactionGovernor(() => undefined);
849957
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
@@ -1207,7 +1315,9 @@ describe("handoff arming (/handoff)", () => {
12071315
{
12081316
title: "noop then cancelManual does not wipe extras from a prior fold",
12091317
fire: "idle",
1210-
midRequests: [{ turns: turnsOfLength(1, 1), text: "wipe this", result: "noop" }],
1318+
midRequests: [
1319+
{ turns: turnsOfLength(1, 1), text: "wipe this", result: "noop" },
1320+
],
12111321
},
12121322
{
12131323
title:

‎src/agent/compaction.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ export function compactFloorNoopNotice(instructions: string): string {
103103
: "Nothing to compact yet.";
104104
}
105105

106+
/** Operator-facing notice after a fold whose post-compact occupancy is still over threshold. */
107+
export function foldNonConvergedNotice(): string {
108+
return "Context fold did not reduce occupancy below the compact threshold. Further automatic folds are paused until usage drops.";
109+
}
110+
106111
/** Run-record `provider:model` (or slash form) as a LastCycleSource. */
107112
export function lastCycleSourceFromRunModel(
108113
model: string | undefined,

‎src/agent/director.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { WorkflowCoordinator } from "../workflows/coordinator.js";
2020
import {
2121
compactionContinuationAction,
2222
createCompactionGovernor,
23+
foldNonConvergedNotice,
2324
type CompactionGovernor,
2425
type HandoffArming,
2526
type ManualCompactArming,
@@ -457,6 +458,8 @@ function applyManageTasksToolCall(
457458
// (inference., tool., reactor., fork.).
458459
export const CHAT_TASKS_CHANGED_EVENT = "custom.chat.tasks.changed";
459460
export const CHAT_TOOLS_ACTIVATE_EVENT = "custom.chat.tools.activate";
461+
export const COMPACTION_FOLD_NONCONVERGED_EVENT =
462+
"custom.compaction.fold_nonconverged";
460463
export const CREDENTIAL_RECOVERY_INTERCHANGE_TYPE =
461464
"system.credential.refresh" as const;
462465
export const ChatTasksChangedDataSchema = type({
@@ -465,6 +468,9 @@ export const ChatTasksChangedDataSchema = type({
465468
export const ChatToolsActivateDataSchema = type({
466469
names: "string[]",
467470
});
471+
export const CompactionFoldNonConvergedDataSchema = type({
472+
notice: "string",
473+
});
468474

469475
export interface ChatDirectorOptions {
470476
// CL-7919: task-boundary classification and workflow coordination are not
@@ -1273,7 +1279,15 @@ class ChatDirectorImpl extends DefaultDirector {
12731279
const cycled = state.lastCycleSource?.sourceId;
12741280
if (cycled !== undefined && cycled !== "") this.currentSourceId = cycled;
12751281
if (onTurnBoundary(event)) {
1282+
const wasNonConverged = this.compaction.foldNonConverged;
12761283
this.compaction.noteInferenceDone(event, turns);
1284+
if (this.compaction.foldNonConverged && !wasNonConverged) {
1285+
this.pendingEmits.push(
1286+
capabilities.emit(COMPACTION_FOLD_NONCONVERGED_EVENT, {
1287+
notice: foldNonConvergedNotice(),
1288+
}),
1289+
);
1290+
}
12771291
}
12781292

12791293
if (

0 commit comments

Comments
 (0)