Skip to content

Commit 756547b

Browse files
committed
Implement session compaction feature with ACP integration
- Added `session/compact` method to compact the durable CrewCoder session in place, returning a summary. - Updated ACP adapter to advertise the new compaction method and handle requests. - Introduced `compactDurableSession` function to manage session compaction logic, including hooks for extension support. - Enhanced event handling to differentiate between automatic and manual compaction, including summary body in notifications. - Created tests for session compaction, including scenarios for previewing compaction and applying edited summaries. - Refactored existing code to support the new compaction functionality and ensure compatibility with the ACP protocol.
1 parent 76ed834 commit 756547b

11 files changed

Lines changed: 608 additions & 59 deletions

crewcoder-agent/AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,8 @@ summary: LLM-generated via the active model client, deterministic fallback
539539
retained recent messages are authoritative for completion status and must be visible to
540540
the summarizer so completed work is not revived as an open thread
541541
events: reuses session_compacted + SessionCompaction[] (no event-stream/schema changes)
542-
manual: crewcoder session compact <id>; TUI /compact, /compact on|off|status
542+
manual: crewcoder session compact <id>; TUI /compact, /compact on|off|status;
543+
ACP session/compact (idle durable rewrite; advertised on initialize._meta)
543544
```
544545

545546
Do not change `currentContextTokens` to use cumulative `totalTokens` as the primary metric — the
@@ -919,6 +920,12 @@ ACP extension: CrewCode calls it after new/load, including with `[]` to revoke s
919920
CrewCoder validates roots on the agent host, persists them in session metadata, and authorizes file
920921
tools through `ToolContext.externalDirectories`; never replace this with a process-global allowlist
921922
or an environment variable. See `docs/EXTERNAL_DIRECTORIES.md`.
923+
`session/compact` is the other host-owned session extension: it compacts the durable session in
924+
place (same rewrite as `crewcoder session compact`), returns the summary, and emits
925+
`_crewcoder/compaction_update` with `automatic: false`. Advertise it on
926+
`initialize._meta["crewcoder/sessionCompact"]`. Do not let CrewCode fall back to local
927+
summary-reset when this method exists. Refuse compact during an in-flight `session/prompt`.
928+
Automatic live compaction still omits the summary body on the update channel.
922929

923930
When a client advertises `clientCapabilities.fs`, `read`/`write`/`edit` route text I/O
924931
through `fs/read_text_file`/`fs/write_text_file` instead of `node:fs`, which is how

crewcoder-agent/docs/ACP_ADAPTER.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@ see "Deliberate deviations" below.
6060
| `session/set_model` | Switches provider/model. Routed through `extMethod` |
6161
| `session/set_approval_mode` | Switches the active session approval/sandbox mode without respawning CrewCoder |
6262
| `session/set_external_directories` | Replaces and persists the session's explicitly granted filesystem roots |
63+
| `session/compact` | Compacts the durable CrewCoder session in place and returns the summary |
6364
| `authenticate` | No-op; credentials are managed by `crewcoder auth` |
6465

6566
Capabilities are reported **honestly**: `loadSession: true`,
6667
`promptCapabilities.image: false`. CrewCoder takes images as on-disk paths, not
6768
inline base64, so advertising the ACP image block would invite a request it cannot
68-
serve.
69+
serve. Manual compact is advertised on `initialize._meta["crewcoder/sessionCompact"]`,
70+
not as a standard ACP session capability.
6971

7072
## Deliberate deviations from the 1.x schema
7173

@@ -100,6 +102,30 @@ dangerous-command tripwire should keep CrewCoder in `review` and auto-resolve or
100102
permission requests instead; setting `full-access` makes Codex unrestricted and prevents that
101103
host from inspecting provider-native commands before execution.
102104

105+
`session/compact` is the host compact-button path. It is an additive extension method
106+
routed through `extMethod`, same as `session/set_model`. `initialize._meta["crewcoder/sessionCompact"]`
107+
advertises `{ method: "session/compact", preview: true, editedSummary: true }` so CrewCode can
108+
stop local summary-reset and compact CrewCoder's durable session instead.
109+
110+
Request: `{ sessionId, preview?, summary? }`. `preview: true` generates the summary without
111+
saving. A non-empty `summary` installs that text as the compacted background. The method
112+
refuses while `session/prompt` is running and refuses sessions that have not been persisted
113+
yet. It is the same rewrite as `crewcoder session compact`: older messages become a
114+
synthetic background summary, recent messages stay, native provider continuation is
115+
cleared, and `lastInputTokens` is reset.
116+
117+
Response:
118+
119+
```txt
120+
compacted, preview, edited, compactionId?, source?, fallbackReason?,
121+
originalMessageCount, retainedMessageCount, summary
122+
```
123+
124+
Host-requested compact emits `_crewcoder/compaction_update` with `automatic: false`. The
125+
completed update **includes `summary`** so the client can replace its local history with
126+
CrewCoder's summary rather than inventing one. Automatic live compaction still omits the
127+
summary body on the update channel.
128+
103129
`session/prompt` reports usage **twice**: `_meta["crewcoder/usage"]` (spec-correct,
104130
full `UsageSummary` including `contextWindow` and `lastInputTokens`) and a top-level
105131
`usage` mirror, which is where hermes-derived clients look.
@@ -130,10 +156,12 @@ overlay can render the current session list without reconstructing mutations.
130156

131157
Compaction has no standard ACP lifecycle shape, so CrewCoder uses the additive
132158
`_crewcoder/compaction_update` session-update kind. It carries `status`,
133-
`automatic`, `percent`, `message`, and optional phase/count/id metadata. The
134-
compacted summary body stays in CrewCoder's durable session and is deliberately
135-
not broadcast to the client. CrewCode understands this extension; standard-only
136-
ACP clients safely ignore the unknown update kind.
159+
`automatic`, `percent`, `message`, and optional phase/count/id metadata. Automatic
160+
live compaction keeps the summary body in CrewCoder's durable session and does not
161+
put it on the update. Host-requested `session/compact` sets `automatic: false` and
162+
includes `summary` on both the RPC result and the completed update. CrewCode
163+
understands this extension; standard-only ACP clients safely ignore the unknown
164+
update kind.
137165

138166
Everything else returns `undefined` and is dropped. CrewCoder's richer vocabulary —
139167
checkpoints, cost ledger, durable goals, compaction preview, token budget,

crewcoder-agent/docs/AUTO_COMPACTION.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ auto-compact. Claude's SDK-native auto-compaction is
4646
also enabled as defense in depth for its opaque resumed session. While running as an ACP agent,
4747
CrewCoder publishes its own compaction lifecycle on the additive
4848
`_crewcoder/compaction_update` session-update kind so capable hosts can show progress before the
49-
next usage snapshot arrives. Codex app-server durable threads
49+
next usage snapshot arrives. CrewCode's compact button must call ACP `session/compact`
50+
(advertised on `initialize._meta["crewcoder/sessionCompact"]`); that rewrites the durable
51+
session the same way `crewcoder session compact` does and returns the summary. Do not
52+
leave host compact as a local transcript summary-reset. Codex app-server durable threads
5053
avoid repeatedly uploading full context across restarts, but they do not change the
5154
model's context-window limit; see
5255
[`CODEX_TRANSPORT.md`](./CODEX_TRANSPORT.md).
@@ -154,6 +157,10 @@ detached in a `finally` so it never keeps the process alive.
154157

155158
### Idle — saved-session command
156159

160+
ACP clients use the same idle path over `session/compact` instead of the CLI. The method
161+
accepts `{ sessionId, preview?, summary? }`, refuses while a prompt is in flight, and
162+
returns the installed summary so the host can update its local transcript.
163+
157164
When no run is active, the TUI shells out to the backend, which atomically replaces the saved
158165
session history and clears native provider continuation before the next resume:
159166

crewcoder-agent/docs/SESSION_COMPACTION_PROGRESS_EVENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ Fields:
3636
`status: "started"`, failures to `"failed"`, and `session_compacted` to
3737
`"completed"`. A skipped no-op closes an already-open progress indicator with a
3838
completed lifecycle status while preserving the explicit skip message and phase.
39-
The payload is marked `automatic: true` because ACP currently exposes no native
40-
manual-compaction request; host-triggered manual compaction remains host-owned.
41-
The summary body is not sent over this notification channel.
39+
Token-triggered live compaction marks the payload `automatic: true` and omits the
40+
summary body. Host-requested `session/compact` marks it `automatic: false` and
41+
includes `summary` on the completed update plus the RPC result, so CrewCode can
42+
replace local history with CrewCoder's durable compact instead of a host-side
43+
summary-reset. ACP still has no standard compact method; this is the namespaced
44+
CrewCoder extension advertised on `initialize._meta["crewcoder/sessionCompact"]`.
4245

4346
Standard-only ACP clients may ignore the unknown namespaced update. CrewCode
4447
parses it into its provider-neutral compaction meter and therefore does not infer

crewcoder-agent/src/acp/acp-agent.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "@agentclientprotocol/sdk";
2828
import { runAgentLoop, type AgentLoopResult } from "../core/agent-loop.js";
2929
import { runAgentLoopContinue } from "../core/agent-loop-continue.js";
30+
import { compactDurableSession } from "../core/compact-session.js";
3031
import { createSessionId } from "../core/session-store.js";
3132
import { setSessionExternalDirectories, validateExternalDirectories } from "../core/external-directories.js";
3233
import { loadSession as loadSessionRecord } from "../core/session-loader.js";
@@ -36,7 +37,7 @@ import { DEFAULT_AGENT_MODE } from "../core/mode-router.js";
3637
import type { AgentEvent } from "../core/events.js";
3738
import type { ApprovalMode } from "../core/approval.js";
3839
import type { AgentMode } from "../core/types.js";
39-
import type { ModelQuestion } from "../core/model-client.js";
40+
import { HeuristicModelClient, type ModelQuestion } from "../core/model-client.js";
4041
import type { ApprovalControlDecision } from "../core/stdin-control.js";
4142
import { ProviderModelClient } from "../providers/provider-model-client.js";
4243
import { listBuiltinProviderModels, resolveModel } from "../providers/model-registry.js";
@@ -81,6 +82,22 @@ type AcpSession = {
8182
type AcpModelInfo = { modelId: string; name: string; description?: string };
8283
type AcpModelState = { availableModels: AcpModelInfo[]; currentModelId: string };
8384

85+
/** Advertised on `initialize._meta` so CrewCode can call compact instead of local summary-reset. */
86+
export const CREWCODER_SESSION_COMPACT_META = {
87+
method: "session/compact",
88+
preview: true,
89+
editedSummary: true
90+
} as const;
91+
92+
const EXT_METHODS = new Set([
93+
"session/set_model",
94+
"session/set_reasoning_effort",
95+
"session/set_approval_mode",
96+
"session/set_external_directories",
97+
"session/follow_up",
98+
"session/compact"
99+
]);
100+
84101
const PERMISSION_OPTIONS: PermissionOption[] = [
85102
{ optionId: "allow_once", name: "Allow", kind: "allow_once" },
86103
{ optionId: "allow_always", name: "Always allow", kind: "allow_always" },
@@ -120,7 +137,8 @@ export class CrewCoderAcpAgent implements Agent {
120137
embeddedContext: false
121138
}
122139
},
123-
authMethods: []
140+
authMethods: [],
141+
_meta: { "crewcoder/sessionCompact": CREWCODER_SESSION_COMPACT_META }
124142
};
125143
}
126144

@@ -179,10 +197,14 @@ export class CrewCoderAcpAgent implements Agent {
179197
* respawning CrewCoder.
180198
*/
181199
async extMethod(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
182-
if (method !== "session/set_model" && method !== "session/set_reasoning_effort" && method !== "session/set_approval_mode" && method !== "session/set_external_directories" && method !== "session/follow_up") throw RequestError.methodNotFound(method);
200+
if (!EXT_METHODS.has(method)) throw RequestError.methodNotFound(method);
183201
const sessionId = typeof params.sessionId === "string" ? params.sessionId : "";
184202
const session = this.session(sessionId);
185203

204+
if (method === "session/compact") {
205+
return this.compactSession(session, params);
206+
}
207+
186208
if (method === "session/follow_up") {
187209
const message = typeof params.message === "string" ? params.message.trim() : "";
188210
if (!message) throw RequestError.invalidParams({ reason: "message is required" });
@@ -238,6 +260,51 @@ export class CrewCoderAcpAgent implements Agent {
238260
return {};
239261
}
240262

263+
private async compactSession(session: AcpSession, params: Record<string, unknown>): Promise<Record<string, unknown>> {
264+
if (session.abort) {
265+
throw RequestError.invalidParams({ reason: "Cannot compact while a prompt is running. Wait for the current turn to finish." });
266+
}
267+
if (!session.started) {
268+
throw RequestError.invalidParams({ reason: "session has no durable transcript yet" });
269+
}
270+
const preview = params.preview === true;
271+
const summary = typeof params.summary === "string" ? params.summary : undefined;
272+
const emit = async (event: AgentEvent): Promise<void> => {
273+
const update = translateEvent(event);
274+
if (update) await this.conn.sessionUpdate({ sessionId: session.sessionId, update: update as unknown as SessionUpdate });
275+
};
276+
try {
277+
const result = await compactDurableSession({
278+
sessionId: session.sessionId,
279+
modelClient: this.modelClientFor(session),
280+
cwd: session.cwd,
281+
preview,
282+
editedSummary: summary,
283+
emit,
284+
automatic: false
285+
});
286+
return {
287+
compacted: result.compacted,
288+
preview: result.preview,
289+
edited: result.edited,
290+
compactionId: result.compactionId,
291+
source: result.source,
292+
fallbackReason: result.fallbackReason,
293+
originalMessageCount: result.originalMessageCount,
294+
retainedMessageCount: result.retainedMessageCount,
295+
summary: result.summary
296+
};
297+
} catch (error) {
298+
throw RequestError.internalError({ message: error instanceof Error ? error.message : String(error) });
299+
}
300+
}
301+
302+
private modelClientFor(session: AcpSession): ProviderModelClient | HeuristicModelClient {
303+
return this.options.heuristic
304+
? new HeuristicModelClient()
305+
: new ProviderModelClient(session.providerId, session.cwd, session.model, undefined, session.reasoningEffort);
306+
}
307+
241308
async cancel(params: CancelNotification): Promise<void> {
242309
const session = this.sessions.get(params.sessionId);
243310
if (!session) return;

crewcoder-agent/src/acp/event-translator.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export type SessionUpdate = SessionNotification["update"];
1616
export interface CrewCoderCompactionUpdate {
1717
sessionUpdate: "_crewcoder/compaction_update";
1818
status: "started" | "completed" | "failed";
19-
automatic: true;
19+
automatic: boolean;
2020
phase?: "requested" | "summarizing" | "saving" | "skipped" | "failed";
2121
percent?: number;
2222
message: string;
2323
compactionId?: string;
2424
originalMessageCount?: number;
2525
retainedMessageCount?: number;
26+
/** Present only for host-requested compact, so CrewCode can replace local history. */
27+
summary?: string;
2628
}
2729

2830
export type CrewCoderSessionUpdate = SessionUpdate | CrewCoderCompactionUpdate;
@@ -81,7 +83,7 @@ export function translateEvent(event: AgentEvent): CrewCoderSessionUpdate | unde
8183
return {
8284
sessionUpdate: "_crewcoder/compaction_update",
8385
status: event.phase === "failed" ? "failed" : event.phase === "skipped" ? "completed" : "started",
84-
automatic: true,
86+
automatic: event.automatic !== false,
8587
phase: event.phase,
8688
percent: event.percent,
8789
message: event.message,
@@ -91,15 +93,17 @@ export function translateEvent(event: AgentEvent): CrewCoderSessionUpdate | unde
9193
}
9294

9395
if (event.type === "session_compacted") {
96+
const automatic = event.automatic !== false;
9497
return {
9598
sessionUpdate: "_crewcoder/compaction_update",
9699
status: "completed",
97-
automatic: true,
100+
automatic,
98101
percent: 100,
99102
message: "Context compacted. Continuing with the retained recent messages and summary.",
100103
compactionId: event.compactionId,
101104
originalMessageCount: event.originalMessageCount,
102-
retainedMessageCount: event.retainedMessageCount
105+
retainedMessageCount: event.retainedMessageCount,
106+
...(automatic ? {} : { summary: event.summary })
103107
};
104108
}
105109

0 commit comments

Comments
 (0)