diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf00a5fcd..e055ed2ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -187,7 +187,7 @@ 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 compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers four 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 a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers five 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. 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. @@ -204,6 +204,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen The `ollama` table key is the bare provider segment; the harness stamps slash-form on `sourceId` (`ollama/default` / `ollama/`), with `provider: openai-compatible` (`buildOpenAISource`) and a bare model (`llama3` / `qwen3`). Idle-recompress disable matches via `isOllamaProviderId` on that `sourceId` (`src/provider/ollama.ts`), not by looking up slash-form as a table key. +- **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. Director rebuild (`/model`, interrupt, resume) restores those instructions from the latest compact record's parameters. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn. - **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 TTL window is measured from the last `inference.done` (every inference rewrites the provider's prefix cache) with one fire per window per compact of any kind, and it never fires with a tool batch outstanding — stall pings can arrive mid-work. The consecutive-compact cap shared with the threshold path still bounds compact→infer→compact. Prompt-caching the summary call itself is an explicit non-goal: the fold carries no cache options. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 2edcdfbc7..89d92ce26 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -138,7 +138,7 @@ src/ commands/ registry.ts Extensible slash-command registry built-in.ts /help, /model, /settings, /permissions, /plugins, - /clear, /new, /mcp (connect providers from /model), + /clear, /new, /compact, /mcp (connect providers from /model), /yolo tui/ shell.ts Transcript, header, status line, prompt, overlays diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 49e8ec01e..35c114dce 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -105,7 +105,7 @@ the file path and parse details. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp` (enable, disable, or remove servers), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. +The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`, `/lexicon`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request-review` is worktree checkout plus a surface pass, loading `/review` for quality rules only; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop — it does not steal planning from `/plan`. Substantial Builder work consumes a counsel / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). `/lexicon` owns director-prompt drift and size against the agents repo at a pinned commit. There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `native-integration`, `typescript`, `ponytail`, and `opsh` stay `use_skill` only (`user-invocable: false`). Bake-only bars such as `idiot-proof` and `native-runtime` are not slashes and are not listed for `use_skill`. Draper and emil are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 1d86af73d..1e4b6c637 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -8,7 +8,10 @@ import type { } from "@intx/types/runtime"; import { COMPACTION_CONTINUATION_EVENT, + OPERATOR_COMPACT_REASON, + compactFloorNoopNotice, createCompactionGovernor, + stickyExtraInstructionsFromRecords, } from "./compaction.js"; import { compactionResumeDeltaFor, @@ -785,6 +788,150 @@ describe("compaction governor", () => { ]); expect(continuations).toBe(1); }); + + test("manual compact bypasses the occupancy governor on an idle session", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + + expect(governor.requestManual("keep the auth discussion")).toBe("kick"); + expect(governor.extraInstructions).toBe("keep the auth discussion"); + expect(governor.requestManual("")).toBe("armed"); + expect(governor.extraInstructions).toBe("keep the auth discussion"); + + const actions = governor.interceptIdleContinuation( + emptyMessage(), + capabilities, + ); + expect( + actions?.some( + (a) => + a.type === "compact" && + "reason" in a && + a.reason === OPERATOR_COMPACT_REASON, + ), + ).toBe(true); + expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter"); + expect(governor.resumeAfterCompact(emptyMessage())).toBeNull(); + }); + + test("manual compact during a tool pause continues the in-flight turn", () => { + const governor = createCompactionGovernor(() => undefined); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + + expect(governor.requestManual("", { inFlight: true })).toBe("armed"); + const actions = governor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect( + actions?.some( + (a) => + a.type === "compact" && + "reason" in a && + a.reason === OPERATOR_COMPACT_REASON, + ), + ).toBe(true); + expect(actions?.some((a) => a.type === "infer")).toBe(false); + expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); + }); + + test("manual compact no-ops below the compactor floor", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns); + expect(governor.requestManual("focus on tests")).toBe("noop"); + expect(governor.extraInstructions).toBeUndefined(); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("manual compact hydrates from restored turns without a prior decide", () => { + const governor = createCompactionGovernor(undefined); + expect(governor.compactTurnCount).toBe(0); + expect(governor.requestManual("keep the auth discussion")).toBe("noop"); + expect( + governor.requestManual("keep the auth discussion", { turns: tenTurns }), + ).toBe("kick"); + expect(governor.compactTurnCount).toBe(10); + expect(governor.extraInstructions).toBe("keep the auth discussion"); + }); + + test("restoreExtraInstructions hydrates a new governor from a compact record", () => { + const written = { + strategy: "pruning-compactor", + version: "1", + parameters: { extraInstructions: "keep the auth discussion" }, + reason: "compacted", + decisions: {}, + }; + expect(stickyExtraInstructionsFromRecords([written])).toBe( + "keep the auth discussion", + ); + + const rebuilt = createCompactionGovernor(undefined); + expect(rebuilt.extraInstructions).toBeUndefined(); + rebuilt.restoreExtraInstructions( + stickyExtraInstructionsFromRecords([written]), + ); + expect(rebuilt.extraInstructions).toBe("keep the auth discussion"); + rebuilt.restoreExtraInstructions(undefined); + expect(rebuilt.extraInstructions).toBe("keep the auth discussion"); + }); + + test("sticky extra instructions skip empty values and later non-pruning records", () => { + expect( + stickyExtraInstructionsFromRecords([ + { + strategy: "other", + parameters: { extraInstructions: "ignore me" }, + }, + { + strategy: "pruning-compactor", + parameters: { extraInstructions: " keep tests " }, + }, + { + strategy: "pruning-compactor", + parameters: { extraInstructions: "older" }, + }, + ]), + ).toBe("keep tests"); + expect( + stickyExtraInstructionsFromRecords([ + { + strategy: "pruning-compactor", + parameters: { extraInstructions: "" }, + }, + { + strategy: "pruning-compactor", + parameters: { extraInstructions: 12 }, + }, + ]), + ).toBeUndefined(); + }); + + test("second /compact during the apply-to-meter window does not double-fold", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect(governor.requestManual("keep the auth discussion")).toBe("kick"); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).not.toBeNull(); + + expect(governor.requestManual("also keep the billing notes")).toBe("armed"); + expect(governor.extraInstructions).toBe("also keep the billing notes"); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter"); + }); + + test("noop /compact below the floor tells the operator instructions were not saved", () => { + expect(compactFloorNoopNotice("")).toBe("Nothing to compact yet."); + expect(compactFloorNoopNotice("keep the auth discussion")).toBe( + "Nothing to compact yet. Instructions were not saved.", + ); + }); }); describe("provider-aware idle recompress (CL-8745)", () => { diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 13e378fb9..aae7756d0 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -54,6 +54,51 @@ export type CompactionGovernor = ReturnType; // requestContinuation closure delivered. Subscribers that only care about // provider/connector traffic must ignore this event. export const COMPACTION_CONTINUATION_EVENT = "custom.compaction.continue"; +/** Compact reason for `/compact` (and later operator-triggered folds). */ +export const OPERATOR_COMPACT_REASON = "operator-request"; +const THRESHOLD_COMPACT_REASON = "context-threshold"; + +/** How `/compact` armed the shared pipeline. */ +export type ManualCompactArming = "kick" | "armed" | "noop"; + +/** Options for an operator-triggered compact. */ +export type ManualCompactOptions = { + inFlight?: boolean; + /** Live or restored turns; used to arm after resume before the first decide. */ + turns?: readonly ConversationTurn[]; +}; + +type CompactRecordLike = { + strategy?: string; + parameters?: Record; +}; + +function extraInstructionsFromCompactRecord( + record: CompactRecordLike, +): string | undefined { + if (record.strategy !== COMPACTOR_NAME) return undefined; + const extra = record.parameters?.extraInstructions; + if (typeof extra !== "string") return undefined; + const trimmed = extra.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Newest-commit-first: first pruning-compactor extraInstructions wins. */ +export function stickyExtraInstructionsFromRecords( + records: readonly CompactRecordLike[], +): string | undefined { + for (const record of records) { + const extra = extraInstructionsFromCompactRecord(record); + if (extra !== undefined) return extra; + } + return undefined; +} + +export function compactFloorNoopNotice(instructions: string): string { + return instructions.trim().length > 0 + ? "Nothing to compact yet. Instructions were not saved." + : "Nothing to compact yet."; +} /** Build the continuation re-entry action for a compacted governor cycle. */ export function compactionContinuationAction( @@ -70,6 +115,11 @@ export function createCompactionGovernor( ) { let pending = false; let idlePending = false; + let manualPending = false; + // Sticky operator instructions from `/compact …`. Empty `/compact` still + // uses the default structured fold; a non-empty argument is kept for later + // auto-folds and written into the compact record. + let extraInstructions: string | undefined; let postCompactInfer = false; // Idle empty compact needs a post-compact decide cycle to adopt the shrunk // turns for the meter, but must not start a new inference (there is no @@ -157,6 +207,16 @@ export function createCompactionGovernor( noteCompactIssued(); } + function compactReason(operator: boolean): string { + return operator ? OPERATOR_COMPACT_REASON : THRESHOLD_COMPACT_REASON; + } + + function clearManualArming(): void { + manualPending = false; + idlePending = false; + pending = false; + } + // Continuation re-entry for a compact-bearing return. The legacy subagent // path still holds a host closure and drives its stall-ping loop through // it; the chat path holds none and gets an emit action the host answers @@ -246,16 +306,22 @@ export function createCompactionGovernor( ): ReactorAction[] | null { if (event.type !== "tool.done") return null; if (outstandingToolCalls > 0) outstandingToolCalls -= 1; - if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) + const operator = manualPending; + if ( + !operator && + !pending && + !(usingEstimate && isOverThreshold(estimate.tokens)) + ) return null; if (!actions.some((a) => a.type === "infer")) return null; - if (atThresholdCompactCap()) return null; - pending = false; + if (!operator && atThresholdCompactCap()) return null; + clearManualArming(); postCompactInfer = true; - issueThresholdCompact(); + if (operator) noteCompactIssued(); + else issueThresholdCompact(); return [ ...actions.filter((a) => a.type !== "infer"), - capabilities.compact(COMPACTOR_NAME, "context-threshold"), + capabilities.compact(COMPACTOR_NAME, compactReason(operator)), ...continuationActions(capabilities), ]; } @@ -276,8 +342,10 @@ export function createCompactionGovernor( event: ReactorInboundEvent, actions: ReactorAction[], ): boolean { - if (!pending || idlePending) return false; - if (atThresholdCompactCap()) return false; + if (idlePending) return false; + const operator = manualPending; + if (!operator && !pending) return false; + if (!operator && atThresholdCompactCap()) return false; if (!onTurnBoundary(event)) return false; if (isSpacerEchoTerminal(event, actions)) return false; const terminal = @@ -352,26 +420,35 @@ export function createCompactionGovernor( ): ReactorAction[] | null { if (event.type !== "message.received") return null; if (idlePending) { - if (atThresholdCompactCap()) { + const operator = manualPending; + if (!operator && atThresholdCompactCap()) { idlePending = false; return null; } - idlePending = false; - pending = false; + clearManualArming(); + const content = inboundText(event); // The reactor delivers no event after compact, so always request a // continuation to re-enter decide against the shrunk turns: // - raced operator content → re-infer to answer it // - empty synthetic continuation → meter-only sync (no infer) - return issueIdleFold( - inboundText(event), - capabilities, - "context-threshold", - ); + if (operator) { + if (content.length > 0) postCompactInfer = true; + else postCompactMeter = true; + noteCompactIssued(); + return [ + capabilities.compact(COMPACTOR_NAME, OPERATOR_COMPACT_REASON), + ...continuationActions(capabilities), + ]; + } + return issueIdleFold(content, capabilities, THRESHOLD_COMPACT_REASON); } // Unarmed idle re-entry past the provider TTL: same fold, same // keep-recent tail, same cap — but a "cache-ttl-recompress" reason so the // fold is attributable. No arming: every live re-entry re-checks the // window, so a sub-agent stall ping or operator message is the trigger. + // In-flight `/compact` (manualPending without idlePending) waits on + // interceptActions; do not steal that hop with a TTL fold. + if (manualPending) return null; if (!isTtlRecompressDue(now())) return null; return issueIdleFold( inboundText(event), @@ -443,6 +520,37 @@ export function createCompactionGovernor( return postCompactInfer || postCompactMeter; } + // `/compact` bypasses the occupancy governor. Same pair-safe compact pipeline + // as auto-compact: in-flight turns wait for interceptActions / noteIdleTurn; + // idle sessions arm an empty continuation so the host can kick decide(). + function requestManual( + instructions: string, + options?: ManualCompactOptions, + ): ManualCompactArming { + if (options?.turns !== undefined) syncFromTurns(options.turns); + if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop"; + const trimmed = instructions.trim(); + if (trimmed.length > 0) extraInstructions = trimmed; + // A compact is already in flight (apply-to-meter or post-compact infer). + // Keep sticky instructions but do not kick a second fold. + if (hasOutstandingContinuation()) return "armed"; + manualPending = true; + if (options?.inFlight === true) return "armed"; + if (idlePending) return "armed"; + idlePending = true; + if (requestContinuation !== undefined) { + requestContinuation(); + return "armed"; + } + return "kick"; + } + + function restoreExtraInstructions(value: string | undefined): void { + const trimmed = value?.trim(); + if (trimmed === undefined || trimmed.length === 0) return; + extraInstructions = trimmed; + } + return { get estimatedTokens(): number { return estimate.tokens; @@ -453,6 +561,14 @@ export function createCompactionGovernor( get usingEstimate(): boolean { return usingEstimate; }, + get extraInstructions(): string | undefined { + return extraInstructions; + }, + get compactTurnCount(): number { + return turnCount; + }, + requestManual, + restoreExtraInstructions, syncFromTurns, noteInferenceDone, notePostCompact, diff --git a/src/agent/director.ts b/src/agent/director.ts index 7687723cb..99a027b2a 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -20,6 +20,8 @@ import { compactionContinuationAction, createCompactionGovernor, type CompactionGovernor, + type ManualCompactArming, + type ManualCompactOptions, } from "./compaction.js"; import { onTurnBoundary } from "./reactor-events.js"; import { isOperatorOriginated } from "./message-provenance.js"; @@ -803,6 +805,25 @@ class ChatDirectorImpl extends DefaultDirector { }; } + requestManualCompact( + instructions: string, + options?: ManualCompactOptions, + ): ManualCompactArming { + return this.compaction.requestManual(instructions, options); + } + + getCompactInstructions(): string | undefined { + return this.compaction.extraInstructions; + } + + restoreCompactInstructions(value: string | undefined): void { + this.compaction.restoreExtraInstructions(value); + } + + getCompactTurnCount(): number { + return this.compaction.compactTurnCount; + } + private openTaskIds(): string[] { return this.tasks .filter((t) => t.status === "todo" || t.status === "doing") @@ -1396,4 +1417,11 @@ export interface ChatDirector extends ReactorDirector { getTasks(): Task[]; restoreTasks(tasks: Task[]): void; getContextEstimate(): { tokens: number; isEstimate: boolean }; + requestManualCompact( + instructions: string, + options?: ManualCompactOptions, + ): ManualCompactArming; + getCompactInstructions(): string | undefined; + restoreCompactInstructions(value: string | undefined): void; + getCompactTurnCount(): number; } diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 8ea8122f9..45e150cd6 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -15,6 +15,10 @@ import { type SessionMetadata, } from "./session/compactor.js"; import { createModelSummarizer } from "./session/summarizer.js"; +import { + createCompactionGovernor, + stickyExtraInstructionsFromRecords, +} from "./agent/compaction.js"; import type { ConversationTurn, InferenceSource, @@ -768,6 +772,64 @@ describe("createPruningCompactor — summarize receives the workflow context (CL }); }); +describe("createPruningCompactor — operator extra instructions", () => { + test("stores extra instructions on the compact record", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 1, + summaryMaxChars: 500, + summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), + summarize: async () => "summary text", + }); + const turns: ConversationTurn[] = [ + makeTurn({ role: "assistant", content: [{ type: "text", text: "a" }] }), + makeTurn({ role: "assistant", content: [{ type: "text", text: "b" }] }), + makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.record.parameters.extraInstructions).toBe( + "keep the auth discussion", + ); + }); + + test("a rebuilt governor still passes stored extra instructions into the next fold", async () => { + const turns: ConversationTurn[] = [ + makeTurn({ role: "assistant", content: [{ type: "text", text: "a" }] }), + makeTurn({ role: "assistant", content: [{ type: "text", text: "b" }] }), + makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), + ]; + const written = await createPruningCompactor({ + keepRecentTurns: 1, + summaryMaxChars: 500, + summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), + summarize: async () => "summary text", + }).apply(turns, mockStrategyCtx); + + const rebuilt = createCompactionGovernor(undefined); + rebuilt.restoreExtraInstructions( + stickyExtraInstructionsFromRecords([written.record]), + ); + + let captured: { extraInstructions?: string } | undefined; + const next = await createPruningCompactor({ + keepRecentTurns: 1, + summaryMaxChars: 500, + summaryContext: () => { + const extra = rebuilt.extraInstructions; + return extra !== undefined ? { extraInstructions: extra } : undefined; + }, + summarize: async (_folded, ctx) => { + captured = ctx; + return "later summary"; + }, + }).apply(turns, mockStrategyCtx); + + expect(captured?.extraInstructions).toBe("keep the auth discussion"); + expect(next.record.parameters.extraInstructions).toBe( + "keep the auth discussion", + ); + }); +}); + describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { function firstText(turn: ConversationTurn): string { const block = turn.content.find((b) => b.type === "text"); diff --git a/src/director.test.ts b/src/director.test.ts index ad3c669e7..192429c5e 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -6,7 +6,10 @@ import { CHAT_TOOLS_ACTIVATE_EVENT, } from "./agent/director.js"; import type { WorkflowCoordinator } from "./workflows/coordinator.js"; -import { COMPACTION_CONTINUATION_EVENT } from "./agent/compaction.js"; +import { + COMPACTION_CONTINUATION_EVENT, + stickyExtraInstructionsFromRecords, +} from "./agent/compaction.js"; import { createAgentToolset } from "./agent/tools.js"; import { createAdvertisedToolset } from "./session/assemble-runtime.js"; import { createPermissionGate } from "./permission/gate.js"; @@ -1142,6 +1145,26 @@ describe("chatDirector compaction", () => { ); expect(afterCompact.some((a) => a.type === "done")).toBe(false); }); + + test("restoreCompactInstructions hydrates a rebuilt director from a compact record", () => { + const written = { + strategy: "pruning-compactor" as const, + version: "1", + parameters: { extraInstructions: "keep the auth discussion" }, + reason: "compacted", + decisions: {}, + }; + const first = chatDirector(""); + first.restoreCompactInstructions( + stickyExtraInstructionsFromRecords([written]), + ); + expect(first.getCompactInstructions()).toBe("keep the auth discussion"); + + const rebuilt = chatDirector(""); + expect(rebuilt.getCompactInstructions()).toBeUndefined(); + rebuilt.restoreCompactInstructions(first.getCompactInstructions()); + expect(rebuilt.getCompactInstructions()).toBe("keep the auth discussion"); + }); }); describe("chatDirector LSP auto-activation", () => { diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 5702d83a0..73a292427 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { Agent } from "@intx/agent"; +import type { Agent, DirectorRegistry } from "@intx/agent"; import type { AuditStore, Compactor, @@ -11,6 +11,7 @@ import type { } from "@intx/types/runtime"; import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; +import type { ChatDirector } from "../agent/director.js"; import { createAdvertisedToolset, loadSessionLocalSettings, @@ -258,6 +259,97 @@ function stubChatAgentWiring( }; } +type ManifestRecord = { + strategy: string; + version: string; + parameters: Record; + reason: string; + decisions: Record; +}; + +function pruningExtrasRecord(extraInstructions: string): ManifestRecord { + return { + strategy: "pruning-compactor", + version: "1", + parameters: { extraInstructions }, + reason: "compacted", + decisions: {}, + }; +} + +function laterManifestRecord(): ManifestRecord { + return { + strategy: "other", + version: "1", + parameters: {}, + reason: "later", + decisions: {}, + }; +} + +async function withAssembledDirector( + input: { + getRecords: () => readonly ManifestRecord[]; + directorHolder: { instance?: ChatDirector }; + wiring?: Partial; + beforeCreateSessionStores?: () => void; + onReadManifestHistory?: (limit: number) => void; + }, + run: (buildAgent: () => Promise) => Promise, +): Promise { + const fakeAgent = { close: async () => undefined } as unknown as Agent; + const fakeStorage = { + readBlob: async () => new Uint8Array(), + readManifestHistory: async (limit: number) => { + input.onReadManifestHistory?.(limit); + return input.getRecords().slice(0, limit); + }, + } as unknown as ContextStore; + + await withMockedModuleDuring( + import.meta.resolve("./optimized-context-store.js"), + (real: typeof import("./optimized-context-store.js")) => ({ + ...real, + createSessionStores: async () => { + input.beforeCreateSessionStores?.(); + return { + storage: fakeStorage, + audit: stubAuditStore(), + }; + }, + }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async ( + _def: unknown, + env: { directors: DirectorRegistry }, + ) => { + env.directors.defaultFactory()({}, {} as never, { + systemPrompt: "prompt", + toolDefinitions: [], + compactorNames: ["pruning-compactor"], + }); + return fakeAgent; + }, + }), + async () => { + const { assembleChatAgent } = await import("./assemble-runtime.js"); + const { buildAgent } = assembleChatAgent( + stubChatAgentWiring({ + directorHolder: input.directorHolder, + ...input.wiring, + }), + ); + await run(buildAgent); + }, + ); + }, + ); +} + describe("assembleChatAgent", () => { test("getWorkdir and getCompactor run at buildAgent time, not assemble time", async () => { const storeDirs: string[] = []; @@ -392,4 +484,126 @@ describe("assembleChatAgent", () => { expect(builtAgent).toBe(fakeAgent); expect(builtStorage).toBe(fakeStorage); }); + + test("rebuild restores extraInstructions from the latest compact record", async () => { + let records: ManifestRecord[] = [ + pruningExtrasRecord("keep the auth discussion"), + ]; + const directorHolder: { instance?: ChatDirector } = {}; + + await withAssembledDirector( + { getRecords: () => records, directorHolder }, + async (buildAgent) => { + await buildAgent(); + expect(directorHolder.instance?.getCompactInstructions()).toBe( + "keep the auth discussion", + ); + // /model store-miss on the same session still inherits fromPrev. + records = []; + await buildAgent(); + expect(directorHolder.instance?.getCompactInstructions()).toBe( + "keep the auth discussion", + ); + }, + ); + }); + + test("/clear-like rebuild against an empty store does not inherit extraInstructions", async () => { + let records: ManifestRecord[] = [ + pruningExtrasRecord("keep the auth discussion"), + ]; + let workdir = "/session-a"; + let sessionId = "session-a"; + const directorHolder: { instance?: ChatDirector } = {}; + + await withAssembledDirector( + { + getRecords: () => records, + directorHolder, + wiring: { + getWorkdir: () => workdir, + getSessionId: () => sessionId, + }, + }, + async (buildAgent) => { + await buildAgent(); + expect(directorHolder.instance?.getCompactInstructions()).toBe( + "keep the auth discussion", + ); + records = []; + workdir = "/session-b"; + sessionId = "session-b"; + await buildAgent(); + expect( + directorHolder.instance?.getCompactInstructions(), + ).toBeUndefined(); + }, + ); + }); + + test("failed /clear rebuild then retry on a new identity does not inherit extraInstructions", async () => { + let records: ManifestRecord[] = [ + pruningExtrasRecord("keep the auth discussion"), + ]; + let workdir = "/session-a"; + let sessionId = "session-a"; + let failStores = false; + const directorHolder: { instance?: ChatDirector } = {}; + + await withAssembledDirector( + { + getRecords: () => records, + directorHolder, + wiring: { + getWorkdir: () => workdir, + getSessionId: () => sessionId, + }, + beforeCreateSessionStores: () => { + if (failStores) throw new Error("store rebuild failed"); + }, + }, + async (buildAgent) => { + await buildAgent(); + expect(directorHolder.instance?.getCompactInstructions()).toBe( + "keep the auth discussion", + ); + records = []; + workdir = "/session-b"; + sessionId = "session-b"; + failStores = true; + await expect(buildAgent()).rejects.toThrow("store rebuild failed"); + failStores = false; + await buildAgent(); + expect( + directorHolder.instance?.getCompactInstructions(), + ).toBeUndefined(); + }, + ); + }); + + test("cold resume restores extraInstructions beyond 32 later cycles", async () => { + const records: ManifestRecord[] = [ + ...Array.from({ length: 32 }, () => laterManifestRecord()), + pruningExtrasRecord("keep the auth discussion"), + ]; + const directorHolder: { instance?: ChatDirector } = {}; + const limits: number[] = []; + + await withAssembledDirector( + { + getRecords: () => records, + directorHolder, + onReadManifestHistory: (limit) => { + limits.push(limit); + }, + }, + async (buildAgent) => { + await buildAgent(); + expect(directorHolder.instance?.getCompactInstructions()).toBe( + "keep the auth discussion", + ); + expect(limits).toEqual([32, 64]); + }, + ); + }); }); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 4e83331c3..1e3d88e51 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -50,6 +50,7 @@ import { } from "../agent/tool-search.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js"; +import { stickyExtraInstructionsFromRecords } from "../agent/compaction.js"; import { createChatDirector, type ChatDirector } from "../agent/director.js"; import { createDoomLoopCorrectiveNote } from "../agent/doom-loop-note.js"; import type { AgentToolset } from "../agent/tools.js"; @@ -517,6 +518,28 @@ export interface AssembledChatAgent { buildAgent: () => Promise; } +const STICKY_COMPACT_MANIFEST_PAGE = 32; + +async function stickyExtraInstructionsFromStore( + storage: ContextStore, +): Promise { + if (typeof storage.readManifestHistory !== "function") return undefined; + try { + let limit = STICKY_COMPACT_MANIFEST_PAGE; + let previousLength = -1; + for (;;) { + const records = await storage.readManifestHistory(limit); + const extra = stickyExtraInstructionsFromRecords(records); + if (extra !== undefined) return extra; + if (records.length === previousLength) return undefined; + previousLength = records.length; + limit *= 2; + } + } catch { + return undefined; + } +} + /** * Define the chat director + agent and build live instances against the * git-backed store. Identical for both runners: the TUI-only deltas @@ -525,10 +548,21 @@ export interface AssembledChatAgent { */ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const directorHolder = wiring.directorHolder ?? {}; + // Newest-commit-first compact records from the store, refreshed each build + // so resume and /model rebuilds restore sticky /compact instructions. + let stickyFromStore: string | undefined; + // /model rebuilds keep the same session store and may miss extras there; + // /clear and /new mint a new workdir, so fromPrev must not follow. + let inheritFromPrev = true; + let lastWorkdir: string | undefined; + let lastSessionId: string | undefined; const chatDirectorDef = defineDirector({ id: `${ID_PREFIX}/chat`, configSchema: type({}), factory: (_cfg, _env, agentCtx) => { + const fromPrev = inheritFromPrev + ? directorHolder.instance?.getCompactInstructions() + : undefined; const d = createChatDirector( agentCtx.systemPrompt, wiring.computeAdvertised([...agentCtx.toolDefinitions]), @@ -543,6 +577,8 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { allowIdleWithFleet: wiring.allowIdleWithFleet, }, ); + d.restoreCompactInstructions(stickyFromStore); + d.restoreCompactInstructions(fromPrev); if (wiring.clearDenials !== undefined) d.setClearDenials(wiring.clearDenials); directorHolder.instance = d; @@ -570,6 +606,14 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const buildAgent = async (): Promise => { const workdir = wiring.getWorkdir(); + const sessionId = wiring.getSessionId(); + inheritFromPrev = + lastWorkdir === undefined || + (lastWorkdir === workdir && lastSessionId === sessionId); + // Do not stamp last* until this build succeeds. A failed /clear factory + // would otherwise make the retry look like the same session and inherit + // extras from the previous director / sticky store snapshot. + if (!inheritFromPrev) stickyFromStore = undefined; const { storage, audit } = await createSessionStores(workdir); // Primary-only evidence archive. Workers never pass evidenceArchiveHolder, so // they keep plain storage and omit admission / authorize recording wraps. @@ -621,6 +665,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { return storage.writeResponse(admitted, signal); }, }; + stickyFromStore = await stickyExtraInstructionsFromStore(storageForAgent); const agent = await createAgentWithLiveToolDispatch(agentDef, { sources: wiring.getSources(), defaultSource: wiring.getDefaultSource(), @@ -648,7 +693,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { ], }, audit, - sessionId: wiring.getSessionId(), + sessionId, // Gate-backed reactor authorization: ask-tier calls suspend via the // vendored approval-suspend primitive instead of parking on a closure. // Finalize evidence admission after guards resolve; never scrub exec args. @@ -678,6 +723,8 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { ? agent : createPrimaryDeliveryAdmission(agent, primaryArchive); wiring.onBuilt(admittedAgent, storageForAgent); + lastWorkdir = workdir; + lastSessionId = sessionId; return admittedAgent; }; diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 173bae1d0..a9c09f6ff 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -242,6 +242,14 @@ const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { maxAnchorTurns: 8, }; +function extraInstructionParameter( + cfg: CompactorConfig, +): { extraInstructions: string } | Record { + const extra = cfg.summaryContext?.()?.extraInstructions?.trim(); + if (extra === undefined || extra.length === 0) return {}; + return { extraInstructions: extra }; +} + // `apply` below no-ops at or below this turn count: keeping `keepRecentTurns` // turns plus at least one more is what makes pruning worth doing at all. export function compactorNoOpFloor(keepRecentTurns: number): number { @@ -888,7 +896,10 @@ export function createPruningCompactor( record: { strategy: this.name, version: this.version, - parameters: { keepRecentTurns: cfg.keepRecentTurns }, + parameters: { + keepRecentTurns: cfg.keepRecentTurns, + ...extraInstructionParameter(cfg), + }, reason: aged.agedImageCount > 0 ? "aged images outside recent window" @@ -998,7 +1009,10 @@ export function createPruningCompactor( record: { strategy: this.name, version: this.version, - parameters: { keepRecentTurns: cfg.keepRecentTurns }, + parameters: { + keepRecentTurns: cfg.keepRecentTurns, + ...extraInstructionParameter(cfg), + }, reason: "no compaction needed", decisions: { summarizedTurnCount: 0, @@ -1034,7 +1048,10 @@ export function createPruningCompactor( record: { strategy: this.name, version: this.version, - parameters: { keepRecentTurns: cfg.keepRecentTurns }, + parameters: { + keepRecentTurns: cfg.keepRecentTurns, + ...extraInstructionParameter(cfg), + }, reason: "summarize failed", decisions: { summarizeFailed: 1, @@ -1049,7 +1066,10 @@ export function createPruningCompactor( record: { strategy: this.name, version: this.version, - parameters: { keepRecentTurns: cfg.keepRecentTurns }, + parameters: { + keepRecentTurns: cfg.keepRecentTurns, + ...extraInstructionParameter(cfg), + }, reason: "summarize failed", decisions: { summarizeFailed: 1, @@ -1106,6 +1126,7 @@ export function createPruningCompactor( keepRecentTurns: cfg.keepRecentTurns, summaryMaxChars: cfg.summaryMaxChars, maxAnchorTurns: cfg.maxAnchorTurns, + ...extraInstructionParameter(cfg), }, reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${keepCount} recent`, decisions: { diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 378686131..985af0bc1 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -139,6 +139,12 @@ export interface SummaryContext { // live in the advertised prefix, not this list — callers pass // activatedToolNames.list() only. activatedTools?: string[]; + /** + * Optional operator guidance from `/compact [instructions]`. Sticky across + * later auto-folds: the governor holds them, and a rebuilt director restores + * them from the latest compact record. + */ + extraInstructions?: string; } const SYSTEM_INSTRUCTION = [ @@ -251,6 +257,12 @@ function contextPreamble(ctx: SummaryContext | undefined): string { `Tools activated via tool_search and still callable directly: ${tools.join(", ")}`, ); } + const extra = ctx?.extraInstructions?.trim(); + if (extra !== undefined && extra.length > 0) { + parts.push( + `Operator compact instructions (honor these while keeping the sections above):\n${extra}`, + ); + } if (parts.length === 0) return ""; return `${parts.join("\n\n")}\n\n`; } diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index fdc6c40ea..9033d7cdc 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -15,6 +15,7 @@ describe("session command registry setup", () => { expect(names).toContain("model"); expect(names).toContain("settings"); expect(names).toContain("clear"); + expect(names).toContain("compact"); expect(getCommand("cost")).toBeDefined(); }); diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 0e09c82b2..6955d719c 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -331,3 +331,48 @@ describe("/feedback command", () => { }); }); }); + +describe("/compact command", () => { + it("is registered with optional instruction hint", () => { + const cmd = defined(getCommand("compact"), "compact"); + expect(cmd.argumentHint).toBe("[optional instructions]"); + }); + + it("explains when compaction is not wired", () => { + expect( + defined(getCommand("compact"), "compact").handler("", makeCtx()), + ).toEqual({ + type: "message", + text: "Compaction is not available in this session.", + }); + }); + + it("passes trailing instructions and does not send a turn", () => { + const seen: string[] = []; + const ctx: CommandContext = { + signalClear: () => undefined, + requestCompact: (instructions) => { + seen.push(instructions); + return undefined; + }, + }; + expect( + defined(getCommand("compact"), "compact").handler( + "keep the auth discussion", + ctx, + ), + ).toEqual({ type: "noop" }); + expect(seen).toEqual(["keep the auth discussion"]); + }); + + it("surfaces an arming error locally", () => { + const ctx: CommandContext = { + signalClear: () => undefined, + requestCompact: () => "Nothing to compact yet.", + }; + expect(defined(getCommand("compact"), "compact").handler("", ctx)).toEqual({ + type: "message", + text: "Nothing to compact yet.", + }); + }); +}); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 884599e2f..062ade22f 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -218,6 +218,23 @@ export function registerBuiltInCommands(): void { }, }); + registerCommand({ + name: "compact", + description: "Fold conversation context now", + argumentHint: "[optional instructions]", + handler: (args, ctx) => { + if (ctx.requestCompact === undefined) { + return { + type: "message", + text: "Compaction is not available in this session.", + }; + } + const err = ctx.requestCompact(args); + if (err !== undefined) return { type: "message", text: err }; + return { type: "noop" }; + }, + }); + // Persist as user-global default, not session-only. registerCommand({ name: "yolo", diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index ce7909ffa..87afad55a 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -28,6 +28,12 @@ export interface CommandContext { getSkipPermissions?: () => boolean; /** Live-flip skip-permissions and persist `/yolo` as the user-global default. */ setSkipPermissions?: (value: boolean) => void; + /** + * Fold conversation context now, bypassing the occupancy governor. + * Optional extra instructions go to the summarizer and stick for later folds. + * Returns an operator-facing error, or undefined when the compact is armed. + */ + requestCompact?: (instructions: string) => string | undefined; } export type CommandResult = diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index f469aa739..bf656d851 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -4,6 +4,8 @@ */ import { getLogger } from "@intx/log"; +import type { ConversationTurn } from "@intx/types/runtime"; +import { compactFloorNoopNotice } from "../../agent/compaction.js"; import type { CommandContext, CommandResult } from "../commands/registry.js"; import { getCommand, setHiddenCommands } from "../commands/registry.js"; import { registerBuiltInCommands } from "../commands/built-in.js"; @@ -42,9 +44,15 @@ import { yoloModeLabel } from "../components/prompt-action-bar-label.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { resolveSessionEffort } from "../../provider/reasoning-effort.js"; import type { InferenceAttemptIdentity } from "./state.js"; -import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; +import { + hostOf, + liveAgent, + type RunnerServices, + type RunnerState, +} from "./state.js"; import { userInboundMessage } from "./submit.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { buildCompactionContinuationMessage } from "../../session/runtime-assembly.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -92,6 +100,8 @@ export function createCommandLayer( }; state.currentAttemptIdentity = currentAttemptIdentity; + let compactHydrateInFlight = false; + const commandContext: CommandContext = { signalClear: () => state.newSession?.(), getSkipPermissions: () => services.permissionGate.getSkipPermissions(), @@ -199,6 +209,52 @@ export function createCommandLayer( beginFeedbackCapture: () => { armFeedbackCapture(); }, + requestCompact: (instructions) => { + const director = services.directorHolder.instance; + const agent = state.currentAgent; + if (director === undefined || agent === undefined) { + return "Compaction is not available in this session."; + } + if ( + state.compactionLifecycle?.isCompacting() === true || + compactHydrateInFlight + ) { + return "Compaction is already in progress."; + } + const arm = (turns?: ConversationTurn[]) => { + const inFlight = state.host?.shell.session.run === "busy"; + const arming = director.requestManualCompact(instructions, { + inFlight, + ...(turns !== undefined ? { turns } : {}), + }); + if (arming === "noop") return compactFloorNoopNotice(instructions); + if (arming === "kick") { + state.enqueueCompactionContinuation?.(() => + liveAgent(state).deliver(buildCompactionContinuationMessage()), + ); + } + return undefined; + }; + if (director.getCompactTurnCount() > 0) return arm(); + // Resume (or a just-built agent) has not decided yet, so the governor's + // turn count is still 0. Load committed history before no-op'ing. + compactHydrateInFlight = true; + void agent + .history() + .then((turns) => { + compactHydrateInFlight = false; + const err = arm(turns); + if (err !== undefined) state.systemNotice?.(err); + }) + .catch((err: unknown) => { + compactHydrateInFlight = false; + tuiLogger.warn("compact history load failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + state.systemNotice?.("Could not read session history to compact."); + }); + return undefined; + }, }; const applyCommandResult = (result: CommandResult): void => { diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index c4b72b4d0..c2fb56bc5 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -638,7 +638,14 @@ export async function assembleTUISession( const summaryContext = (): SummaryContext | undefined => { const status = workflowHost.status(); const activatedTools = activatedToolNames.list(); - if (!status.active && activatedTools.length === 0) return undefined; + const extraInstructions = directorHolder.instance?.getCompactInstructions(); + if ( + !status.active && + activatedTools.length === 0 && + (extraInstructions === undefined || extraInstructions.length === 0) + ) { + return undefined; + } return { ...(status.active ? { @@ -651,6 +658,9 @@ export async function assembleTUISession( } : {}), ...(activatedTools.length > 0 ? { activatedTools } : {}), + ...(extraInstructions !== undefined && extraInstructions.length > 0 + ? { extraInstructions } + : {}), }; }; diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index f0bcc6d5f..d069250cb 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -85,6 +85,14 @@ test("buildSummaryPrompt omits workflow preamble when none active", () => { expect(prompt).toContain("Session excerpt"); }); +test("buildSummaryPrompt injects operator compact instructions", () => { + const prompt = buildSummaryPrompt(turns(), { + extraInstructions: "keep the auth discussion", + }); + expect(prompt).toContain("Operator compact instructions"); + expect(prompt).toContain("keep the auth discussion"); +}); + test("model summarizer returns the model output", async () => { const summarize = createModelSummarizer({ getSource: () => source,