Skip to content

Commit 4893df7

Browse files
Enderfgaclaude
andcommitted
feat(autoloop): auto-compact on context-budget threshold
Closes the last design-doc deferred item (§7.1). Each agent gets a /compact dispatched when its contextPercent crosses the per-role threshold (Planner 80%, Coder 70%, Reviewer 70%) — uses Claude Code's built-in /compact slash command with an agent-specific preservation hint, so session id is kept and no manual memory-file dance is needed. Implementation: - dispatcher.maybeCompact(agent, name) in src/autoloop/dispatcher.ts — reads getStatus(name).stats.contextPercent, fires compactSession() when over threshold, 30s cooldown to prevent back-to-back fires - per-agent compactSummaryFor() builds the /compact <hint> text: Planner keeps plan/goal/user decisions; Coder keeps codebase familiarity + attempted patches; Reviewer keeps fakery patterns + recent metrics - called at the end of deliverToPlanner / deliverToCoder / deliverToReviewer (both happy path and clarification path for Coder, both verdict and no-verdict path for Reviewer) - new 'compact' SSE event on /autoloop/<id>/events, plumbed via embedded-server's existing SSE relay - ClaudeAgentDispatcherConfig.compactThresholds for per-run override Dashboard: renders compact events inline in the relevant pane as '[auto-compact <pct>% ≥ <threshold>% — /compact dispatched]' system entries. 501/501 tests pass; build/lint/format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fce43b0 commit 4893df7

5 files changed

Lines changed: 128 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [3.5.3] - 2026-05-10
9+
10+
### Added — auto-compact on context-budget threshold
11+
12+
Each agent session is monitored after every turn via `getStats().contextPercent`.
13+
When it crosses the per-agent threshold the dispatcher dispatches `/compact`
14+
with an agent-specific summary hint:
15+
16+
| Agent | Default threshold | What `/compact` is told to preserve |
17+
|---|---|---|
18+
| Planner | 80% | current plan / goal, decisions with the user, what's been tried + rejected, user prefs, iter verdicts |
19+
| Coder | 70% | codebase familiarity, attempted patches, current working state, plan + goal |
20+
| Reviewer | 70% | fakery patterns caught, recent metrics, structural rules from goal.json |
21+
22+
Per-run override via `compactThresholds: { planner?, coder?, reviewer? }` on
23+
the dispatcher config. 30-second cooldown prevents back-to-back compactions.
24+
25+
Surfaces as a new `compact` SSE event on `/autoloop/<id>/events` (alongside
26+
`planner_reply` / `coder_reply` / `reviewer_reply`); the embedded dashboard
27+
renders it as an inline `[auto-compact 82% ≥ 80% — /compact dispatched]`
28+
system entry in the relevant pane.
29+
30+
Closes the last design-doc deferred item (auto-compact, design doc §7.1).
31+
Manual `autoloop_reset_agent` still available for nuclear reset.
32+
833
## [3.5.2] - 2026-05-10
934

1035
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@enderfga/claw-orchestrator",
3-
"version": "3.5.2",
3+
"version": "3.5.3",
44
"description": "Claw Orchestrator — run Claude Code, Codex, Gemini, Cursor Agent, OpenCode and custom coding CLIs as one unified runtime for claw-style agent systems. Runs standalone, with first-class OpenClaw plugin support. Persistent sessions, multi-agent council, tool orchestration.",
55
"type": "module",
66
"main": "./dist/src/index.js",

src/autoloop/dispatcher.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export interface ClaudeAgentDispatcherConfig {
5656
/** Per-message wall-clock cap. Default 10 min. */
5757
sendTimeoutMs?: number;
5858
logger?: Logger;
59+
/**
60+
* Auto-compact thresholds (percent of context window). When the agent's
61+
* `contextPercent` (from getStats) climbs above its threshold after a
62+
* turn, the dispatcher dispatches `/compact <agent-specific summary>` to
63+
* that agent. Defaults: Planner 80%, Coder 70%, Reviewer 70%.
64+
*
65+
* Per the design doc §7: each agent's context is precious; don't let it
66+
* silently fill until the API rejects.
67+
*/
68+
compactThresholds?: { planner?: number; coder?: number; reviewer?: number };
5969
/**
6070
* Push-policy ref that S3's update_push_policy mutates. Caller (SessionManager)
6171
* passes its own policy object so changes are visible to the runner.
@@ -228,6 +238,71 @@ export class ClaudeAgentDispatcher extends EventEmitter implements AgentDispatch
228238
}
229239
}
230240

241+
// ─── Auto-compact ────────────────────────────────────────────────────────
242+
//
243+
// After each agent turn we check getStats().contextPercent. When it crosses
244+
// the per-agent threshold we send `/compact <hint>` to ask Claude Code to
245+
// drop chunks of history while preserving what each role needs to keep
246+
// working. /compact preserves the session id — no reset, no memory-file
247+
// dance, no reprime — so this is cheap.
248+
//
249+
// We track lastCompactAt per agent to avoid re-firing within 30 s in case
250+
// the immediate post-compact stats haven't refreshed yet.
251+
252+
private lastCompactAt: Partial<Record<'planner' | 'coder' | 'reviewer', number>> = {};
253+
254+
private compactSummaryFor(agent: 'planner' | 'coder' | 'reviewer'): string {
255+
if (agent === 'planner') {
256+
return [
257+
'Preserve: current plan.md state and goal.json criteria; what the user has asked',
258+
"for and approved; what directions have been tried and rejected; the user's style",
259+
'preferences for this run; iter-by-iter Reviewer verdicts. Drop: verbose tool',
260+
'output, intermediate file dumps, redundant context.',
261+
].join(' ');
262+
}
263+
if (agent === 'coder') {
264+
return [
265+
'Preserve: codebase familiarity (what files do what), what patches you have already',
266+
'tried and why they failed, what is currently working, the current plan and goal.',
267+
'Drop: full file dumps, verbose stack traces, intermediate eval output beyond the',
268+
'last few iters.',
269+
].join(' ');
270+
}
271+
return [
272+
'Preserve: patterns of fakery you have caught (in reviewer_memory.md), recent metric',
273+
'history, structural rules from goal.json, your accumulating model of what cheating',
274+
'looks like in this codebase. Drop: full diff dumps from older iters, verbose audit',
275+
'transcripts beyond the last few iters.',
276+
].join(' ');
277+
}
278+
279+
private async maybeCompact(agent: 'planner' | 'coder' | 'reviewer', name: string): Promise<void> {
280+
const cfg = this.config.compactThresholds ?? {};
281+
const threshold =
282+
agent === 'planner' ? (cfg.planner ?? 80) : agent === 'coder' ? (cfg.coder ?? 70) : (cfg.reviewer ?? 70);
283+
let pct: number | undefined;
284+
try {
285+
const stats = this.config.manager.getStatus(name).stats;
286+
pct = stats.contextPercent;
287+
} catch {
288+
// Session might be gone (terminate races); silent skip.
289+
return;
290+
}
291+
if (pct == null || pct < threshold) return;
292+
const last = this.lastCompactAt[agent] ?? 0;
293+
if (Date.now() - last < 30_000) return;
294+
this.lastCompactAt[agent] = Date.now();
295+
this.logger.info?.(
296+
`[autoloop/${this.config.runId}] ${agent} context ${pct.toFixed(0)}% ≥ ${threshold}% — auto-compact`,
297+
);
298+
this.emit('compact', { agent, percent: pct, threshold });
299+
try {
300+
await this.config.manager.compactSession(name, this.compactSummaryFor(agent));
301+
} catch (err) {
302+
this.logger.warn?.(`[autoloop/${this.config.runId}] compact ${agent} failed: ${(err as Error).message}`);
303+
}
304+
}
305+
231306
// ─── Planner-specific ────────────────────────────────────────────────────
232307

233308
private async ensurePlanner(): Promise<void> {
@@ -318,6 +393,8 @@ export class ClaudeAgentDispatcher extends EventEmitter implements AgentDispatch
318393

319394
// Emit cleaned reply (without raw JSON blocks) for the chat tool to surface.
320395
if (parsed.cleaned_reply) this.emit('planner_reply', parsed.cleaned_reply);
396+
// Auto-compact after each Planner turn if context is filling up.
397+
await this.maybeCompact('planner', this.plannerName);
321398
return handlerResult.emitted_messages;
322399
}
323400

@@ -393,6 +470,7 @@ export class ClaudeAgentDispatcher extends EventEmitter implements AgentDispatch
393470
if (!ic) {
394471
// No iter_complete emitted — could be a clarification request, or the
395472
// Coder bailed. Return a directive_ack so Planner sees it next turn.
473+
await this.maybeCompact('coder', this.coderName);
396474
return [
397475
Msg.directiveAck(env.iter, {
398476
understood: false,
@@ -424,6 +502,7 @@ export class ClaudeAgentDispatcher extends EventEmitter implements AgentDispatch
424502
const commitMsg = `autoloop/iter-${env.iter}: ${ic.summary}`.slice(0, 200);
425503
await this.runGit(['git', 'commit', '-m', commitMsg]);
426504

505+
await this.maybeCompact('coder', this.coderName);
427506
return [
428507
Msg.iterArtifacts(env.iter, {
429508
diff: diffOut.out,
@@ -522,10 +601,12 @@ export class ClaudeAgentDispatcher extends EventEmitter implements AgentDispatch
522601
metric: null,
523602
audit_notes: verdict.payload.audit_notes,
524603
});
604+
await this.maybeCompact('reviewer', this.reviewerName);
525605
return [verdict];
526606
}
527607

528608
this.persistVerdict(env.payload.iter, rc);
609+
await this.maybeCompact('reviewer', this.reviewerName);
529610
return [Msg.reviewVerdict(env.payload.iter, rc)];
530611
}
531612

src/dashboard/index.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,24 @@ <h2 id="sidebarTitle">Runs</h2>
629629
});
630630
paintPlanner();
631631
});
632+
es.addEventListener('compact', (e) => {
633+
const data = JSON.parse(e.data);
634+
const entry = {
635+
who: 'system',
636+
text: `[auto-compact ${data.percent.toFixed(0)}% ≥ ${data.threshold}% — /compact dispatched]`,
637+
ts: new Date().toISOString(),
638+
};
639+
if (data.agent === 'planner') {
640+
state.autoloop.plannerLog.push(entry);
641+
paintPlanner();
642+
} else if (data.agent === 'coder') {
643+
state.autoloop.coderLog.push(entry);
644+
paintCoder();
645+
} else if (data.agent === 'reviewer') {
646+
state.autoloop.reviewerLog.push(entry);
647+
paintReviewer();
648+
}
649+
});
632650
}
633651

634652
function paintAutoloopState() {

src/embedded-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ export class EmbeddedServer {
576576
const onPlannerReply = (text: unknown): void => send('planner_reply', { text });
577577
const onCoderReply = (text: unknown): void => send('coder_reply', { text });
578578
const onReviewerReply = (text: unknown): void => send('reviewer_reply', { text });
579+
const onCompact = (e: unknown): void => send('compact', e);
579580
const cleanup = (): void => {
580581
ctx.runner.off('message', onMessage);
581582
ctx.runner.off('state', onState);
@@ -585,6 +586,7 @@ export class EmbeddedServer {
585586
ctx.dispatcher.off('planner_reply', onPlannerReply);
586587
ctx.dispatcher.off('coder_reply', onCoderReply);
587588
ctx.dispatcher.off('reviewer_reply', onReviewerReply);
589+
ctx.dispatcher.off('compact', onCompact);
588590
try {
589591
res.end();
590592
} catch {
@@ -599,6 +601,7 @@ export class EmbeddedServer {
599601
ctx.dispatcher.on('planner_reply', onPlannerReply);
600602
ctx.dispatcher.on('coder_reply', onCoderReply);
601603
ctx.dispatcher.on('reviewer_reply', onReviewerReply);
604+
ctx.dispatcher.on('compact', onCompact);
602605
res.on('close', cleanup);
603606
return;
604607
}

0 commit comments

Comments
 (0)