Skip to content

Commit 776cd01

Browse files
refactor(chat): express compaction continuation as reactor action (#1041)
* feat(chat): express compaction continuation as a reactor emit action Removes requestContinuation from ChatDirectorOptions. The compaction governor now appends a custom.compaction.continue emit action wherever it previously invoked the closure, and the TUI and exec hosts answer that event by delivering the compaction continuation message. * fix(chat): validate continuation events before delivering after compact (#1051) * fix(chat): validate continuation events before delivering after compact Hosts answer each compaction continuation emit at most once via a seq-keyed consume-once gate, and the director holds unsolicited empty continuations with wait instead of inferring, so forged or replayed events cannot burn billable model turns. * fix(chat): pin continuation polish leftovers from emit-and-deliver (#1060) Summary: update the architecture doc to the emit-and-deliver continuation mechanism; collapse idle-turn arming into a single-delivery contract (closure fires and the flag stays false, or no closure and the flag reports the arming) with a guard test; extract the session deliver generation re-check into runGenerationGuardedDeliver with race-pinning tests. Emit shape, guards, and gates unchanged. Verification: bun test src/tui/deliver-agent-message.test.ts src/agent/compaction.test.ts (38 pass); bun run check (7396 pass, 0 fail).
1 parent 01d810a commit 776cd01

16 files changed

Lines changed: 482 additions & 121 deletions

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen
177177
- **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.
178178
- **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.
179179

180-
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.
180+
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 pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. 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. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver.
181181

182182
Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session.
183183

src/agent/compaction.test.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import type {
66
ReactorInboundEvent,
77
TokenUsage,
88
} from "@intx/types/runtime";
9-
import { createCompactionGovernor } from "./compaction.js";
9+
import {
10+
COMPACTION_CONTINUATION_EVENT,
11+
createCompactionGovernor,
12+
} from "./compaction.js";
1013
import {
1114
compactionResumeDeltaFor,
1215
compactionThresholdFor,
@@ -28,6 +31,11 @@ const capabilities = {
2831
compactor,
2932
reason,
3033
}),
34+
emit: (eventType: string, data: unknown) => ({
35+
type: "emit",
36+
eventType,
37+
data,
38+
}),
3139
} as unknown as ReactorCapabilities;
3240

3341
// Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total
@@ -182,15 +190,34 @@ describe("compaction governor", () => {
182190
).toBeNull();
183191
});
184192

185-
test("stays inert without a continuation channel", () => {
193+
test("expresses continuation as an emit action without a closure", () => {
186194
const governor = createCompactionGovernor(undefined);
187195
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
196+
const actions = governor.interceptActions(
197+
toolDone(),
198+
inferAction,
199+
capabilities,
200+
);
201+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
202+
expect(actions?.some((a) => a.type === "infer")).toBe(false);
188203
expect(
189-
governor.interceptActions(toolDone(), inferAction, capabilities),
190-
).toBeNull();
191-
expect(
192-
governor.interceptOverflow(overflowError(), capabilities),
193-
).toBeNull();
204+
actions?.some(
205+
(a) =>
206+
a.type === "emit" &&
207+
"eventType" in a &&
208+
a.eventType === COMPACTION_CONTINUATION_EVENT,
209+
),
210+
).toBe(true);
211+
expect(
212+
governor
213+
.interceptOverflow(overflowError(), capabilities)
214+
?.some(
215+
(a) =>
216+
a.type === "emit" &&
217+
"eventType" in a &&
218+
a.eventType === COMPACTION_CONTINUATION_EVENT,
219+
),
220+
).toBe(true);
194221
});
195222

196223
test("recovers from context overflow a bounded number of times", () => {
@@ -241,6 +268,61 @@ describe("compaction governor", () => {
241268
).toBeNull();
242269
});
243270

271+
test("an idle over-threshold turn arms an emit continuation without a closure", () => {
272+
const governor = createCompactionGovernor(undefined);
273+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
274+
275+
const terminal: ReactorAction[] = [{ type: "reply", content: "done" }];
276+
expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe(
277+
true,
278+
);
279+
// Only arms once even if the idle turn is observed again.
280+
expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe(
281+
false,
282+
);
283+
284+
const actions = governor.interceptIdleContinuation(
285+
emptyMessage(),
286+
capabilities,
287+
);
288+
expect(
289+
actions?.some(
290+
(a) =>
291+
a.type === "compact" &&
292+
"reason" in a &&
293+
a.reason === "context-threshold",
294+
),
295+
).toBe(true);
296+
expect(
297+
actions?.some(
298+
(a) =>
299+
a.type === "emit" &&
300+
"eventType" in a &&
301+
a.eventType === COMPACTION_CONTINUATION_EVENT,
302+
),
303+
).toBe(true);
304+
});
305+
306+
test("idle arming with a closure fires once and never returns true", () => {
307+
// Single-delivery contract: a caller that both installs the legacy closure
308+
// and honors the boolean (appending an emit action on true) must still
309+
// deliver exactly once. The closure fires; the return stays false.
310+
let continuations = 0;
311+
const governor = createCompactionGovernor(() => continuations++);
312+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
313+
314+
const terminal: ReactorAction[] = [{ type: "reply", content: "done" }];
315+
expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe(
316+
false,
317+
);
318+
expect(continuations).toBe(1);
319+
// A repeated idle turn neither refires nor arms.
320+
expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe(
321+
false,
322+
);
323+
expect(continuations).toBe(1);
324+
});
325+
244326
// Idle compact with an empty continuation previously left postCompactInfer
245327
// unset, so resumeAfterCompact never fired and notePostCompact never ran —
246328
// the Ctx meter stayed on pre-compact lastTurnUsage until the next user turn.

src/agent/compaction.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,26 @@ const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2;
4040
// A compact action runs in its own reactor cycle, after which the reactor
4141
// idles until the next inbound event. Worker loops (sub-agents, the coding
4242
// director) have no operator to send that next message, so the governor swaps
43-
// the post-tool infer for a compact action, asks the host to deliver an empty
44-
// continuation message, and re-issues the infer when that message arrives.
45-
// Without a continuation channel the governor stays inert: stalling the loop
46-
// would be worse than growing the context.
43+
// the post-tool infer for a compact action, requests a continuation re-entry,
44+
// and re-issues the infer when that message arrives. Without a continuation
45+
// channel the governor stays inert: stalling the loop would be worse than
46+
// growing the context.
4747
export type CompactionGovernor = ReturnType<typeof createCompactionGovernor>;
4848

49+
// Continuation re-entry expressed as a ReactorAction: the reactor emits this
50+
// event on the agent stream and the host answers it with
51+
// buildCompactionContinuationMessage(), the same message the old
52+
// requestContinuation closure delivered. Subscribers that only care about
53+
// provider/connector traffic must ignore this event.
54+
export const COMPACTION_CONTINUATION_EVENT = "custom.compaction.continue";
55+
56+
/** Build the continuation re-entry action for a compacted governor cycle. */
57+
export function compactionContinuationAction(
58+
capabilities: ReactorCapabilities,
59+
): ReactorAction {
60+
return capabilities.emit(COMPACTION_CONTINUATION_EVENT, {});
61+
}
62+
4963
export function createCompactionGovernor(
5064
requestContinuation?: () => void,
5165
systemPrompt = "",
@@ -119,6 +133,20 @@ export function createCompactionGovernor(
119133
noteCompactIssued();
120134
}
121135

136+
// Continuation re-entry for a compact-bearing return. The legacy subagent
137+
// path still holds a host closure and drives its stall-ping loop through
138+
// it; the chat path holds none and gets an emit action the host answers
139+
// with a deliver instead.
140+
function continuationActions(
141+
capabilities: ReactorCapabilities,
142+
): ReactorAction[] {
143+
if (requestContinuation !== undefined) {
144+
requestContinuation();
145+
return [];
146+
}
147+
return [compactionContinuationAction(capabilities)];
148+
}
149+
122150
function isSpacerEchoTerminal(
123151
event: ReactorInboundEvent,
124152
actions: ReactorAction[],
@@ -141,7 +169,6 @@ export function createCompactionGovernor(
141169
if (event.turn.content.some((block) => block.type === "tool_call")) {
142170
consecutiveThresholdCompacts = 0;
143171
}
144-
if (requestContinuation === undefined) return;
145172
syncFromTurns(turns);
146173
lastModel = event.source?.model;
147174
const reportedTokens = contextTokensFromUsage(event.usage);
@@ -190,31 +217,43 @@ export function createCompactionGovernor(
190217
pending = false;
191218
postCompactInfer = true;
192219
issueThresholdCompact();
193-
requestContinuation?.();
194220
return [
195221
...actions.filter((a) => a.type !== "infer"),
196222
capabilities.compact(COMPACTOR_NAME, "context-threshold"),
223+
...continuationActions(capabilities),
197224
];
198225
}
199226

200227
// Interactive sessions can end a turn with a reply and then sit idle, so a
201228
// pending compaction would wait indefinitely for the next tool batch. When
202-
// the turn ends without follow-up work, ask the host for a continuation and
229+
// the turn ends without follow-up work, request a continuation re-entry and
203230
// compact when it (or the operator's next message) arrives.
231+
//
232+
// Single-delivery contract: the closure channel and the boolean return are
233+
// mutually exclusive, matching continuationActions below. When a legacy
234+
// closure is installed (the sub-agent path) it fires here and this returns
235+
// false, so a caller that also honors the return cannot double-deliver.
236+
// When no closure is installed (the chat path) nothing fires and the return
237+
// reports whether this call newly armed the idle continuation — the caller
238+
// must then append compactionContinuationAction to its returned actions.
204239
function noteIdleTurn(
205240
event: ReactorInboundEvent,
206241
actions: ReactorAction[],
207-
): void {
208-
if (!pending || idlePending || requestContinuation === undefined) return;
209-
if (atThresholdCompactCap()) return;
210-
if (!onTurnBoundary(event)) return;
211-
if (isSpacerEchoTerminal(event, actions)) return;
242+
): boolean {
243+
if (!pending || idlePending) return false;
244+
if (atThresholdCompactCap()) return false;
245+
if (!onTurnBoundary(event)) return false;
246+
if (isSpacerEchoTerminal(event, actions)) return false;
212247
const terminal =
213248
actions.some((a) => a.type === "reply" || a.type === "wait") &&
214249
!actions.some((a) => a.type === "infer" || a.type === "execute_tools");
215-
if (!terminal) return;
250+
if (!terminal) return false;
216251
idlePending = true;
217-
requestContinuation();
252+
if (requestContinuation !== undefined) {
253+
requestContinuation();
254+
return false;
255+
}
256+
return true;
218257
}
219258

220259
function interceptIdleContinuation(
@@ -240,8 +279,10 @@ export function createCompactionGovernor(
240279
postCompactMeter = true;
241280
}
242281
issueThresholdCompact();
243-
requestContinuation?.();
244-
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
282+
return [
283+
capabilities.compact(COMPACTOR_NAME, "context-threshold"),
284+
...continuationActions(capabilities),
285+
];
245286
}
246287

247288
// A context-overflow inference error would otherwise terminate the loop
@@ -251,7 +292,6 @@ export function createCompactionGovernor(
251292
event: ReactorInboundEvent,
252293
capabilities: ReactorCapabilities,
253294
): ReactorAction[] | null {
254-
if (requestContinuation === undefined) return null;
255295
if (
256296
event.type !== "inference.error" ||
257297
event.error.category !== "context_overflow"
@@ -263,8 +303,10 @@ export function createCompactionGovernor(
263303
pending = false;
264304
postCompactInfer = true;
265305
noteCompactIssued();
266-
requestContinuation();
267-
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
306+
return [
307+
capabilities.compact(COMPACTOR_NAME, "context-overflow"),
308+
...continuationActions(capabilities),
309+
];
268310
}
269311

270312
// After compact, a content-less continuation re-enters decide. "infer" means
@@ -296,6 +338,14 @@ export function createCompactionGovernor(
296338
usingEstimate = true;
297339
}
298340

341+
// True while the governor expects the host to answer a continuation emit.
342+
// The post-compact resume flags are consume-on-hit, so an empty
343+
// message.received that finds neither set is unsolicited — forged or a
344+
// replayed duplicate — and answering it would burn a billable inference.
345+
function hasOutstandingContinuation(): boolean {
346+
return postCompactInfer || postCompactMeter;
347+
}
348+
299349
return {
300350
get estimatedTokens(): number {
301351
return estimate.tokens;
@@ -314,5 +364,6 @@ export function createCompactionGovernor(
314364
interceptIdleContinuation,
315365
interceptOverflow,
316366
resumeAfterCompact,
367+
hasOutstandingContinuation,
317368
};
318369
}

src/agent/director.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "../session/compactor.js";
2222
import type { WorkflowCoordinator } from "../workflows/coordinator.js";
2323
import {
24+
compactionContinuationAction,
2425
createCompactionGovernor,
2526
type CompactionGovernor,
2627
} from "./compaction.js";
@@ -444,7 +445,6 @@ export interface ChatDirectorOptions {
444445
inactivityTimeoutMs?: number | undefined;
445446
totalTimeoutMs?: number | undefined;
446447
workflowCoordinator?: WorkflowCoordinator | undefined;
447-
requestContinuation?: (() => void) | undefined;
448448
provider?: { providerName: string; model?: string } | undefined;
449449
/**
450450
* CL-7918 decisions (both former closures removed, no new env key):
@@ -572,8 +572,10 @@ class ChatDirectorImpl extends DefaultDirector {
572572
this.totalTimeoutMs = options.totalTimeoutMs;
573573
this.taskClassifier = options.taskClassifier;
574574
this.workflowCoordinator = options.workflowCoordinator;
575+
// The chat path holds no continuation closure: the governor expresses
576+
// continuation as an emit action the host answers with a deliver.
575577
this.compaction = createCompactionGovernor(
576-
options.requestContinuation,
578+
undefined,
577579
composedPrompt,
578580
toolDefinitions,
579581
);
@@ -769,6 +771,22 @@ class ChatDirectorImpl extends DefaultDirector {
769771
const recovery = this.compaction.interceptOverflow(event, capabilities);
770772
if (recovery !== null) return recovery;
771773

774+
// A forged or replayed compaction continuation arrives as an empty
775+
// message.received with no outstanding compact state (the legit resume
776+
// is consumed above). Answering it with infer would burn a billable
777+
// model turn and reset the loop-protection budgets below, so hold the
778+
// loop instead.
779+
if (event.type === "message.received") {
780+
const content =
781+
typeof event.message.content === "string" ? event.message.content : "";
782+
if (
783+
content.length === 0 &&
784+
!this.compaction.hasOutstandingContinuation()
785+
) {
786+
return capabilities.wait();
787+
}
788+
}
789+
772790
// Only `aborted` (internal-recovery-abort) lands here: the harness's own
773791
// retry policy already owns `timeout`/`retryable`/`quota_exhausted` and
774792
// has exhausted its own attempt budget (up to MAX_ATTEMPTS full-context
@@ -1064,13 +1082,21 @@ class ChatDirectorImpl extends DefaultDirector {
10641082
);
10651083
}
10661084

1067-
this.compaction.noteIdleTurn(event, baseActions);
1085+
// Idle arming returns an emit action (continuation as a ReactorAction)
1086+
// so the host re-enters the loop and the governor can compact on the
1087+
// continuation's arrival.
1088+
const idleContinuationArmed = this.compaction.noteIdleTurn(
1089+
event,
1090+
baseActions,
1091+
);
10681092
const compacted = this.compaction.interceptActions(
10691093
event,
10701094
baseActions,
10711095
capabilities,
10721096
);
10731097
if (compacted !== null) return compacted;
1098+
if (idleContinuationArmed)
1099+
return [...baseActions, compactionContinuationAction(capabilities)];
10741100

10751101
// Loop protection takes precedence over workflow/open-task
10761102
// continuation nudges below: those exist to keep a session moving,

0 commit comments

Comments
 (0)