diff --git a/packages/coding-agent/skills/compact/SKILL.md b/packages/coding-agent/skills/compact/SKILL.md new file mode 100644 index 000000000..4a5d2e020 --- /dev/null +++ b/packages/coding-agent/skills/compact/SKILL.md @@ -0,0 +1,41 @@ +--- +name: compact +description: Check context usage and compact the conversation from IPython. Use when context is filling up and substantial work remains, so the session is summarized and you keep working instead of stopping early. +--- + +# Compact + +Compaction replaces older conversation history with a dense summary, freeing +context so long-running work can continue. The implementation lives in the +host (the same one behind the user's `/compact` command); this skill is the +kernel-side interface to it. Call it directly from IPython: + +```python +await compact.status() +await compact.run() +await compact.run("keep the failing test names and the migration checklist") +``` + +## API + +- `await compact.status()` — current context usage as a dict: `tokens`, + `context_window`, and `percent` (`None` right after a compaction until the + next model response), plus `scheduled` (whether a requested compaction is + already pending). +- `await compact.run(instructions=None)` — schedule compaction. Returns + `{"scheduled": True}`, or `{"scheduled": False, "reason": ...}` when there + is nothing to compact yet. Optional `instructions` focus the summary on + what matters for the remaining work. + +## Rules + +- Compaction never runs mid-cell. A scheduled compaction runs when the + current turn ends; the harness then resumes you automatically with the + summary plus recent messages, and you continue the task. +- The IPython kernel persists through compaction — variables, imports, and + helpers you defined all remain available. +- Compact at a natural boundary when context usage is high and substantial + work remains, instead of becoming terse or returning to the user early. + Check `await compact.status()` when unsure. +- One request per turn is enough; calling `run` again before the turn ends + only updates the instructions. diff --git a/packages/coding-agent/skills/compact/pyproject.toml b/packages/coding-agent/skills/compact/pyproject.toml new file mode 100644 index 000000000..7899d7b10 --- /dev/null +++ b/packages/coding-agent/skills/compact/pyproject.toml @@ -0,0 +1,17 @@ +# Kernel-side package for the bundled compact skill. It only talks to the host +# through rlm.host_request; prime-agent-runtime is always installed in the +# kernel venv before skills, so it is intentionally not declared as a +# dependency (it is not published on PyPI). +[project] +name = "compact" +version = "0.1.0" +description = "Prime Agent compact skill: context compaction control over the host bridge" +requires-python = ">=3.10" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/compact"] diff --git a/packages/coding-agent/skills/compact/src/compact/__init__.py b/packages/coding-agent/skills/compact/src/compact/__init__.py new file mode 100644 index 000000000..685749b3e --- /dev/null +++ b/packages/coding-agent/skills/compact/src/compact/__init__.py @@ -0,0 +1,39 @@ +"""Prime Agent compact skill: context compaction control from the kernel. + +Compaction runs host-side (the same implementation as /compact); these +functions are thin typed wrappers over the generic host bridge +(`rlm.host_request`). They only work inside the Prime Agent IPython kernel. +""" + +from __future__ import annotations + +from typing import Any + +from rlm import host_request + + +async def status() -> dict[str, Any]: + """Read current context usage. + + Returns a dict with `tokens`, `context_window`, `percent` (None right + after a compaction until the next model response), and `scheduled` + (whether a requested compaction is already pending for this turn). + """ + return await host_request("compact.status") + + +async def run(instructions: str | None = None) -> dict[str, Any]: + """Schedule context compaction. + + Compaction never runs mid-cell: it runs when the current turn ends and + the harness resumes you automatically afterwards. Returns + `{"scheduled": True}`, or `{"scheduled": False, "reason": ...}` when + there is nothing to compact. Optional `instructions` focus the summary on + what matters for the remaining work. + """ + if instructions is not None and not isinstance(instructions, str): + raise TypeError(f"instructions must be str or None, got {type(instructions).__name__}") + payload: dict[str, Any] = {} + if instructions is not None: + payload["instructions"] = instructions + return await host_request("compact.run", payload) diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 3fe935a5d..d06948c81 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -289,6 +289,7 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { allowedToolNames: options.allowedToolNames, customTools: options.customTools, includeGoals: options.includeGoals, + includeCompactSkill: options.includeCompactSkill, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.sessionDir, diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 701de2e81..ae053107e 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -55,6 +55,7 @@ export interface AgentSessionCreationOptions { initialActiveToolNames?: string[]; allowedToolNames?: string[]; includeGoals?: boolean; + includeCompactSkill?: boolean; agentMessageController?: AgentSessionMessageController; agentObserveController?: AgentObserveController; rlmDepth?: number; @@ -235,6 +236,7 @@ export async function createAgentSessionFromServices( initialActiveToolNames: options.initialActiveToolNames, allowedToolNames: options.allowedToolNames, includeGoals: options.includeGoals, + includeCompactSkill: options.includeCompactSkill, agentMessageController: options.agentMessageController, agentObserveController: options.agentObserveController, rlmDepth: options.rlmDepth, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 968a3f0cf..db06983a3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -74,6 +74,7 @@ import { import type { AuthSourceToken } from "./auth-storage.js"; import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; import { + COMPACT_SKILL_NAME, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -223,6 +224,8 @@ export interface RlmChildAgentSnapshot { error?: string; } +export type CompactionReason = "manual" | "threshold" | "overflow" | "requested"; + /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | AgentEvent @@ -231,12 +234,12 @@ export type AgentSessionEvent = steering: readonly string[]; followUp: readonly string[]; } - | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow"; customInstructions?: string } + | { type: "compaction_start"; reason: CompactionReason; customInstructions?: string } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "compaction_end"; - reason: "manual" | "threshold" | "overflow"; + reason: CompactionReason; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; @@ -311,6 +314,11 @@ export interface AgentSessionConfig { agentMessageController?: AgentSessionMessageController; /** Daemon-backed read-only active-session observation bridge. Omitted for local-only sessions. */ agentObserveController?: AgentObserveController; + /** + * Whether the bundled compact skill and its compact.* host handlers are + * available to the model. Default: the compaction.agentCallable setting. + */ + includeCompactSkill?: boolean; /** * Optional host-side controller for the bundled rlm-heartbeat Python skill. * When omitted, rlm_heartbeat.* host requests are unavailable. @@ -581,6 +589,7 @@ export class AgentSession { private _autoCompactionAbortController: AbortController | undefined = undefined; private _overflowRecoveryAttempted = false; private _continueAfterThresholdCompaction = false; + private _pendingRequestedCompaction: { customInstructions?: string } | undefined; // Branch summarization state private _branchSummaryAbortController: AbortController | undefined = undefined; @@ -617,6 +626,7 @@ export class AgentSession { private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; private _includeGoals: boolean; + private _includeCompactSkill: boolean; private _rlmHeartbeatController?: AgentRlmHeartbeatController; private _agentMessageController?: AgentSessionMessageController; private _agentObserveController?: AgentObserveController; @@ -695,6 +705,7 @@ export class AgentSession { this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._includeGoals = config.includeGoals ?? true; + this._includeCompactSkill = config.includeCompactSkill ?? this.settingsManager.getCompactionAgentCallable(); this._rlmHeartbeatController = config.rlmHeartbeatController; this._agentMessageController = config.agentMessageController; this._agentObserveController = config.agentObserveController; @@ -1241,6 +1252,16 @@ export class AgentSession { private _shouldStopForThresholdCompaction(context: ShouldStopAfterTurnContext): boolean { this._continueAfterThresholdCompaction = false; + if (this._pendingRequestedCompaction === undefined && !this._thresholdCompactionNeeded(context)) { + return false; + } + + const lastMessage = this.agent.state.messages[this.agent.state.messages.length - 1]; + this._continueAfterThresholdCompaction = lastMessage !== undefined && lastMessage.role !== "assistant"; + return true; + } + + private _thresholdCompactionNeeded(context: ShouldStopAfterTurnContext): boolean { const settings = this.settingsManager.getCompactionSettings(); if (!settings.enabled) return false; @@ -1252,13 +1273,7 @@ export class AgentSession { } const contextTokens = this._getThresholdContextTokens(context.message, compactionTimestamp); - if (contextTokens === undefined || !shouldCompact(contextTokens, contextWindow, settings)) { - return false; - } - - const lastMessage = this.agent.state.messages[this.agent.state.messages.length - 1]; - this._continueAfterThresholdCompaction = lastMessage !== undefined && lastMessage.role !== "assistant"; - return true; + return contextTokens !== undefined && shouldCompact(contextTokens, contextWindow, settings); } /** @@ -1289,6 +1304,59 @@ export class AgentSession { } } + /** + * Handle a compact.* request from the kernel host bridge. Compaction would + * abort the run executing the requesting cell, so compact.run only schedules + * it; _checkCompaction consumes the request at the turn boundary. + */ + handleCompactHostRequest(type: string, payload: Record = {}): Record { + if (!this._includeCompactSkill) { + throw new Error("the compact skill is disabled in this session"); + } + switch (type) { + case "compact.status": { + const usage = this.getContextUsage(); + return { + tokens: usage?.tokens ?? null, + context_window: usage?.contextWindow ?? null, + percent: usage?.percent ?? null, + scheduled: this._pendingRequestedCompaction !== undefined, + }; + } + case "compact.run": { + const instructions = payload.instructions; + if (instructions !== undefined && typeof instructions !== "string") { + throw new Error("compact.run instructions must be a string when provided"); + } + // "status" is reserved by the host-request reply protocol; don't use it as a key. + if (!this.isStreaming) { + return { + scheduled: false, + reason: "no active turn; compaction can only be requested while a turn is running", + }; + } + const preparation = prepareCompaction( + this.sessionManager.getBranch(), + this.settingsManager.getCompactionSettings(), + ); + if (!preparation) { + const lastEntry = this.sessionManager.getBranch().at(-1); + return { + scheduled: false, + reason: lastEntry?.type === "compaction" ? "already compacted" : "session is too short to compact", + }; + } + this._pendingRequestedCompaction = { customInstructions: instructions }; + return { + scheduled: true, + note: "Compaction runs when the current turn ends; you resume automatically afterwards. Continue working normally.", + }; + } + default: + throw new Error(`unknown compact request type "${type}"`); + } + } + /** * Handle an rlm_heartbeat.* request from the bundled rlm-heartbeat skill. * These heartbeats are internal to this active session and never read or @@ -3363,116 +3431,27 @@ export class AgentSession { } const { apiKey, headers } = await this._getRequiredRequestAuth(this.model); - - const pathEntries = this.sessionManager.getBranch(); - const settings = this.settingsManager.getCompactionSettings(); - - const preparation = prepareCompaction(pathEntries, settings); - if (!preparation) { - // Check why we can't compact - const lastEntry = pathEntries[pathEntries.length - 1]; - if (lastEntry?.type === "compaction") { - throw new CompactionSkippedError("Already compacted"); - } - throw new CompactionSkippedError("Session is too short to compact — try again once it grows"); - } - - let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; - - if (this._extensionRunner.hasHandlers("session_before_compact")) { - const result = (await this._extensionRunner.emit({ - type: "session_before_compact", - preparation, - branchEntries: pathEntries, - customInstructions, - signal: this._compactionAbortController.signal, - })) as SessionBeforeCompactResult | undefined; - - if (result?.cancel) { - throw new Error("Compaction cancelled"); - } - - if (result?.compaction) { - extensionCompaction = result.compaction; - fromExtension = true; - } - } - - let summary: string; - let firstKeptEntryId: string; - let tokensBefore: number; - let details: unknown; - - if (extensionCompaction) { - // Extension provided compaction content - summary = extensionCompaction.summary; - firstKeptEntryId = extensionCompaction.firstKeptEntryId; - tokensBefore = extensionCompaction.tokensBefore; - details = extensionCompaction.details; - } else { - // Generate compaction result - const result = await compact( - preparation, - this.model, - apiKey, - headers, - customInstructions, - this._compactionAbortController.signal, - this.thinkingLevel, - ); - summary = result.summary; - firstKeptEntryId = result.firstKeptEntryId; - tokensBefore = result.tokensBefore; - details = result.details; - } - - if (this._compactionAbortController.signal.aborted) { - throw new Error("Compaction cancelled"); - } - - this.sessionManager.appendCompaction( - summary, - firstKeptEntryId, - tokensBefore, - details, - fromExtension, + const result = await this._performCompaction({ + model: this.model, + apiKey, + headers, customInstructions, - ); - const newEntries = this.sessionManager.getEntries(); - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; - - // Get the saved compaction entry for the extension event - const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as - | CompactionEntry - | undefined; - - if (this._extensionRunner && savedCompactionEntry) { - await this._extensionRunner.emit({ - type: "session_compact", - compactionEntry: savedCompactionEntry, - fromExtension, - }); - } - await this._notifyKernelStateAfterCompaction(); + signal: this._compactionAbortController.signal, + }); - const compactionResult = { - summary, - firstKeptEntryId, - tokensBefore, - details, - }; this._emit({ type: "compaction_end", reason: "manual", - result: compactionResult, + result, aborted: false, willRetry: false, customInstructions, }); didCompact = true; - return compactionResult; + // A manual compaction satisfies any pending model request; on failure the + // request stays scheduled for the next turn boundary. + this._pendingRequestedCompaction = undefined; + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); @@ -3501,6 +3480,88 @@ export class AgentSession { } } + /** + * Shared compaction core behind /compact, auto-compaction, and the compact + * skill. Throws CompactionSkippedError when there is nothing to compact and + * Error("Compaction cancelled") on abort or extension cancel. + */ + private async _performCompaction(options: { + model: Model; + apiKey: string; + headers?: Record; + customInstructions?: string; + signal: AbortSignal; + }): Promise { + const { model, apiKey, headers, customInstructions, signal } = options; + const pathEntries = this.sessionManager.getBranch(); + const settings = this.settingsManager.getCompactionSettings(); + + const preparation = prepareCompaction(pathEntries, settings); + if (!preparation) { + const lastEntry = pathEntries[pathEntries.length - 1]; + if (lastEntry?.type === "compaction") { + throw new CompactionSkippedError("Already compacted"); + } + throw new CompactionSkippedError("Session is too short to compact — try again once it grows"); + } + + let extensionCompaction: CompactionResult | undefined; + let fromExtension = false; + + if (this._extensionRunner.hasHandlers("session_before_compact")) { + const result = (await this._extensionRunner.emit({ + type: "session_before_compact", + preparation, + branchEntries: pathEntries, + customInstructions, + signal, + })) as SessionBeforeCompactResult | undefined; + + if (result?.cancel) { + throw new Error("Compaction cancelled"); + } + + if (result?.compaction) { + extensionCompaction = result.compaction; + fromExtension = true; + } + } + + const { summary, firstKeptEntryId, tokensBefore, details } = + extensionCompaction ?? + (await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel)); + + if (signal.aborted) { + throw new Error("Compaction cancelled"); + } + + this.sessionManager.appendCompaction( + summary, + firstKeptEntryId, + tokensBefore, + details, + fromExtension, + customInstructions, + ); + const newEntries = this.sessionManager.getEntries(); + this.agent.state.messages = this.sessionManager.buildSessionContext().messages; + + // Get the saved compaction entry for the extension event + const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as + | CompactionEntry + | undefined; + if (savedCompactionEntry) { + await this._extensionRunner.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension, + }); + } + await this._notifyKernelStateAfterCompaction(); + + return { summary, firstKeptEntryId, tokensBefore, details }; + } + /** * Cancel in-progress compaction (manual or auto). */ @@ -3994,12 +4055,14 @@ export class AgentSession { } private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { - const settings = this.settingsManager.getCompactionSettings(); - if (!settings.enabled) return false; - - // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false - if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; + // An abort drops any compaction the model requested this turn, even on the + // pre-prompt path (skipAbortedCheck=false) which continues to threshold checks. + if (assistantMessage.stopReason === "aborted") { + this._pendingRequestedCompaction = undefined; + if (skipAbortedCheck) return false; + } + const settings = this.settingsManager.getCompactionSettings(); const contextWindow = this.model?.contextWindow ?? 0; // Skip overflow check if the message came from a different model. @@ -4009,19 +4072,22 @@ export class AgentSession { const sameModel = this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id; - // Skip compaction checks if this assistant message is older than the latest - // compaction boundary. This prevents a stale pre-compaction usage/error + // Skip overflow/threshold checks if this assistant message is older than the + // latest compaction boundary. This prevents a stale pre-compaction usage/error // from retriggering compaction on the first prompt after compaction. const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); const compactionTimestamp = compactionEntry ? new Date(compactionEntry.timestamp).getTime() : undefined; const assistantIsFromBeforeCompaction = compactionTimestamp !== undefined && assistantMessage.timestamp <= compactionTimestamp; - if (assistantIsFromBeforeCompaction) { - return false; - } - // Case 1: Overflow - LLM returned context overflow error - if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { + // Case 1: Overflow - takes priority over a pending model request so the error + // strip + retry still happen; the compaction it runs consumes the request. + if ( + !assistantIsFromBeforeCompaction && + (settings.enabled || this._pendingRequestedCompaction !== undefined) && + sameModel && + isContextOverflow(assistantMessage, contextWindow) + ) { if (this._overflowRecoveryAttempted) { this._emit({ type: "compaction_end", @@ -4045,7 +4111,14 @@ export class AgentSession { return await this._runAutoCompaction("overflow", true); } - // Case 2: Threshold - context is getting large. + // Case 2: Model-requested (compact skill); runs even with auto-compaction off. + if (this._pendingRequestedCompaction !== undefined) { + return await this._runAutoCompaction("requested", false); + } + + if (!settings.enabled || assistantIsFromBeforeCompaction) return false; + + // Case 3: Threshold - context is getting large. // Use the full-session estimate so messages appended after the last successful // assistant usage are included, matching the /usage context display. const contextTokens = this._getThresholdContextTokens(assistantMessage, compactionTimestamp); @@ -4057,14 +4130,30 @@ export class AgentSession { } /** - * Internal: Run auto-compaction with events. + * Internal: Run automatic (threshold/overflow) or model-requested compaction + * with events. */ - private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { - const settings = this.settingsManager.getCompactionSettings(); - const shouldContinueAfterThreshold = reason === "threshold" && this._continueAfterThresholdCompaction; + private async _runAutoCompaction( + reason: "overflow" | "threshold" | "requested", + willRetry: boolean, + ): Promise { + // Any compaction consumes a pending model request and honors its instructions + // (overflow recovery can fire first and take the request with it). + const pending = this._pendingRequestedCompaction; + this._pendingRequestedCompaction = undefined; + const customInstructions = pending?.customInstructions; + const shouldContinueAfterCompaction = + (reason === "threshold" || reason === "requested") && this._continueAfterThresholdCompaction; this._continueAfterThresholdCompaction = false; - this._emit({ type: "compaction_start", reason }); + // A requested compaction stopped the loop on purpose; don't stall if it fails. + const resumeAfterFailure = () => { + if (reason === "requested" && (shouldContinueAfterCompaction || this.agent.hasQueuedMessages())) { + this._schedulePostCompactionContinue(); + } + }; + + this._emit({ type: "compaction_start", reason, customInstructions }); this._autoCompactionAbortController = new AbortController(); try { @@ -4076,6 +4165,7 @@ export class AgentSession { aborted: false, willRetry: false, }); + resumeAfterFailure(); return false; } @@ -4088,122 +4178,21 @@ export class AgentSession { aborted: false, willRetry: false, }); + resumeAfterFailure(); return false; } - const { apiKey, headers } = authResult; - const pathEntries = this.sessionManager.getBranch(); - - const preparation = prepareCompaction(pathEntries, settings); - if (!preparation) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: "Auto-compaction skipped: nothing to summarize outside the recent-context window", - errorSeverity: "warning", - }); - return false; - } - - let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; - - if (this._extensionRunner.hasHandlers("session_before_compact")) { - const extensionResult = (await this._extensionRunner.emit({ - type: "session_before_compact", - preparation, - branchEntries: pathEntries, - customInstructions: undefined, - signal: this._autoCompactionAbortController.signal, - })) as SessionBeforeCompactResult | undefined; - - if (extensionResult?.cancel) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: true, - willRetry: false, - }); - return false; - } - - if (extensionResult?.compaction) { - extensionCompaction = extensionResult.compaction; - fromExtension = true; - } - } - - let summary: string; - let firstKeptEntryId: string; - let tokensBefore: number; - let details: unknown; - - if (extensionCompaction) { - // Extension provided compaction content - summary = extensionCompaction.summary; - firstKeptEntryId = extensionCompaction.firstKeptEntryId; - tokensBefore = extensionCompaction.tokensBefore; - details = extensionCompaction.details; - } else { - // Generate compaction result - const compactResult = await compact( - preparation, - this.model, - apiKey, - headers, - undefined, - this._autoCompactionAbortController.signal, - this.thinkingLevel, - ); - summary = compactResult.summary; - firstKeptEntryId = compactResult.firstKeptEntryId; - tokensBefore = compactResult.tokensBefore; - details = compactResult.details; - } - - if (this._autoCompactionAbortController.signal.aborted) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: true, - willRetry: false, - }); - return false; - } - - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); - const newEntries = this.sessionManager.getEntries(); - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; - - // Get the saved compaction entry for the extension event - const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as - | CompactionEntry - | undefined; - - if (this._extensionRunner && savedCompactionEntry) { - await this._extensionRunner.emit({ - type: "session_compact", - compactionEntry: savedCompactionEntry, - fromExtension, - }); - } - await this._notifyKernelStateAfterCompaction(); + const result = await this._performCompaction({ + model: this.model, + apiKey: authResult.apiKey, + headers: authResult.headers, + customInstructions, + signal: this._autoCompactionAbortController.signal, + }); - const result: CompactionResult = { - summary, - firstKeptEntryId, - tokensBefore, - details, - }; - this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry, customInstructions }); const hasQueuedMessages = this.agent.hasQueuedMessages(); - const willContinueAfterCompaction = willRetry || shouldContinueAfterThreshold || hasQueuedMessages; + const willContinueAfterCompaction = willRetry || shouldContinueAfterCompaction || hasQueuedMessages; if (willRetry) { const messages = this.agent.state.messages; @@ -4215,8 +4204,8 @@ export class AgentSession { this._schedulePostCompactionContinue(); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); return true; - } else if (shouldContinueAfterThreshold || hasQueuedMessages) { - // Threshold compaction can intentionally stop a tool loop between turns. + } else if (shouldContinueAfterCompaction || hasQueuedMessages) { + // Compaction can intentionally stop a tool loop between turns. // Queued follow-up/steering/custom messages can also be waiting. this._schedulePostCompactionContinue(); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); @@ -4226,6 +4215,36 @@ export class AgentSession { return false; } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; + const aborted = + errorMessage === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + if (aborted) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: true, + willRetry: false, + customInstructions, + }); + return false; + } + if (error instanceof CompactionSkippedError) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "requested" + ? `Requested compaction skipped: ${errorMessage}` + : `Auto-compaction skipped: ${errorMessage}`, + errorSeverity: "warning", + customInstructions, + }); + resumeAfterFailure(); + return false; + } this._emit({ type: "compaction_end", reason, @@ -4235,8 +4254,12 @@ export class AgentSession { errorMessage: reason === "overflow" ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, + : reason === "requested" + ? `Requested compaction failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + customInstructions, }); + resumeAfterFailure(); return false; } finally { this._autoCompactionAbortController = undefined; @@ -4662,13 +4685,16 @@ export class AgentSession { /** * Skills exposed to the model (system prompt + kernel). The bundled goal - * skill is withheld when goals are disabled for this session. + * and compact skills are withheld when disabled for this session. */ private _modelVisibleSkills(): Skill[] { let skills = this._resourceLoader.getSkills().skills; if (!this._includeGoals) { skills = skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); } + if (!this._includeCompactSkill) { + skills = skills.filter((skill) => skill.name !== COMPACT_SKILL_NAME); + } if (!this._agentMessageController) { skills = skills.filter((skill) => skill.name !== AGENT_MESSAGE_SKILL_NAME); } @@ -4698,6 +4724,11 @@ export class AgentSession { handlers[type] = async (payload) => this.handleGoalHostRequest(type, payload); } } + if (this._includeCompactSkill) { + for (const type of ["compact.run", "compact.status"]) { + handlers[type] = async (payload) => this.handleCompactHostRequest(type, payload); + } + } if (this._rlmHeartbeatController) { for (const type of [ "rlm_heartbeat.list", @@ -4942,6 +4973,7 @@ export class AgentSession { allowedToolNames: this._allowedToolNames ? [...this._allowedToolNames] : undefined, customTools: [...this._customTools], includeGoals: this._includeGoals, + includeCompactSkill: this._includeCompactSkill, rlmDepth: this._rlmDepth + 1, rlmMaxDepth: this._rlmMaxDepth, rlmParentNodeId: options.id, @@ -5022,6 +5054,7 @@ export class AgentSession { initialActiveToolNames: options.activeToolNames, allowedToolNames: options.allowedToolNames, includeGoals: options.includeGoals, + includeCompactSkill: options.includeCompactSkill, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.sessionDir, diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 7c8e10c5e..f1602cf82 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -117,6 +117,8 @@ export interface CompactionResult { // Types // ============================================================================ +export const COMPACT_SKILL_NAME = "compact"; + export interface CompactionSettings { enabled: boolean; reserveTokens: number; diff --git a/packages/coding-agent/src/core/rlm-runtime.ts b/packages/coding-agent/src/core/rlm-runtime.ts index 41555344f..b87a207b8 100644 --- a/packages/coding-agent/src/core/rlm-runtime.ts +++ b/packages/coding-agent/src/core/rlm-runtime.ts @@ -62,6 +62,7 @@ export interface CreateRlmSubagentRuntimeOptions { allowedToolNames?: string[]; customTools: ToolDefinition[]; includeGoals: boolean; + includeCompactSkill: boolean; rlmDepth: number; rlmMaxDepth: number; rlmParentNodeId: string; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index fe1ae18fa..432774c37 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -367,6 +367,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} initialActiveToolNames, allowedToolNames, includeGoals, + includeCompactSkill: options.includeCompactSkill, rlmHeartbeatController: options.rlmHeartbeatController, agentMessageController: options.agentMessageController, agentObserveController: options.agentObserveController, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index e38812c55..cb18a20be 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -11,6 +11,7 @@ export interface CompactionSettings { enabled?: boolean; // default: true reserveTokens?: number; // default: 16384 keepRecentTokens?: number; // default: 20000 + agentCallable?: boolean; // default: true - expose the compact skill so the model can request compaction } export interface BranchSummarySettings { @@ -763,6 +764,10 @@ export class SettingsManager { return this.settings.compaction?.keepRecentTokens ?? 20000; } + getCompactionAgentCallable(): boolean { + return this.settings.compaction?.agentCallable ?? true; + } + getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } { return { enabled: this.getCompactionEnabled(), diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a6da98bd0..1a481fa9c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -703,6 +703,7 @@ export function resolveRuntimeSessionOptions( initialActiveToolNames: runtimeSessionOptions?.initialActiveToolNames, allowedToolNames: runtimeSessionOptions?.allowedToolNames, includeGoals: runtimeSessionOptions?.includeGoals, + includeCompactSkill: runtimeSessionOptions?.includeCompactSkill, rlmHeartbeatController: runtimeSessionOptions?.rlmHeartbeatController, agentMessageController: runtimeSessionOptions?.agentMessageController, agentObserveController: runtimeSessionOptions?.agentObserveController, diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index cb04fbb6b..36bec8888 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -452,12 +452,16 @@ export type AgentConnectionSessionEvent = steering: readonly string[]; followUp: readonly string[]; } - | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow"; customInstructions?: string } + | { + type: "compaction_start"; + reason: "manual" | "threshold" | "overflow" | "requested"; + customInstructions?: string; + } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "compaction_end"; - reason: "manual" | "threshold" | "overflow"; + reason: "manual" | "threshold" | "overflow" | "requested"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 48c171093..5d21cba0d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -902,6 +902,7 @@ export class AgentDaemon { allowedToolNames: options.allowedToolNames, customTools: options.customTools, includeGoals: options.includeGoals, + includeCompactSkill: options.includeCompactSkill, rlmHeartbeatController: { listRlmHeartbeats: (listOptions) => { if (!stateRef) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5a4bf90cd..3da45d6b1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4291,7 +4291,9 @@ export class InteractiveMode { const label = event.reason === "manual" ? `Compacting context${focus}... ${cancelHint}` - : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; + : event.reason === "requested" + ? `Agent requested compaction, compacting context${focus}... ${cancelHint}` + : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; this.autoCompactionLoader = new Loader( this.ui, (spinner) => theme.fg("muted", spinner), diff --git a/packages/coding-agent/test/builtin-skills.test.ts b/packages/coding-agent/test/builtin-skills.test.ts index 8144459df..2e4d6c00a 100644 --- a/packages/coding-agent/test/builtin-skills.test.ts +++ b/packages/coding-agent/test/builtin-skills.test.ts @@ -236,6 +236,15 @@ describe("builtin skills", () => { expect(goal?.kind === "python" && goal.python.importName).toBe("goal"); }); + it("loads the bundled compact skill as a python skill", () => { + const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); + + const compact = skills.find((s) => s.name === "compact"); + expect(compact).toBeDefined(); + expect(compact?.kind).toBe("python"); + expect(compact?.kind === "python" && compact.python.importName).toBe("compact"); + }); + it("loads the bundled RLM heartbeat skill as a python skill", () => { const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); diff --git a/packages/coding-agent/test/suite/agent-session-compact-skill.test.ts b/packages/coding-agent/test/suite/agent-session-compact-skill.test.ts new file mode 100644 index 000000000..64a835d4b --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-compact-skill.test.ts @@ -0,0 +1,300 @@ +import type { ShouldStopAfterTurnContext } from "@earendil-works/pi-agent-core"; +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, type Harness } from "./harness.js"; + +type SessionInternals = { + _checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise; + _shouldStopAfterTurn: (context: ShouldStopAfterTurnContext) => boolean; + _createKernelHostHandlers: () => Record; +}; + +function createAssistant( + harness: Harness, + options: { stopReason?: AssistantMessage["stopReason"]; errorMessage?: string } = {}, +) { + const model = harness.getModel(); + return { + ...fauxAssistantMessage("", { + stopReason: options.stopReason, + errorMessage: options.errorMessage, + timestamp: Date.now(), + }), + api: model.api, + provider: model.provider, + model: model.id, + }; +} + +function setStreaming(harness: Harness, streaming: boolean) { + (harness.session.agent.state as { isStreaming: boolean }).isStreaming = streaming; +} + +/** Extension that supplies compaction content so no provider call is needed. */ +function extensionCompaction() { + return (pi: any) => { + pi.on("session_before_compact", async (event: any) => ({ + compaction: { + summary: "requested summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }; +} + +describe("AgentSession compact skill host requests", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("schedules via compact.run and compacts at the turn boundary with reason requested", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + const runResult = harness.session.handleCompactHostRequest("compact.run", { instructions: "keep the plan" }); + setStreaming(harness, false); + expect(runResult.scheduled).toBe(true); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(true); + + const internals = harness.session as unknown as SessionInternals; + const compacted = await internals._checkCompaction(createAssistant(harness)); + expect(compacted).toBe(false); // no retry needed + + const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + expect(compactionEntries).toHaveLength(1); + expect(harness.eventsOfType("compaction_start").at(-1)).toMatchObject({ + reason: "requested", + customInstructions: "keep the plan", + }); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ reason: "requested" }); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + }); + + it("reports skipped when there is nothing to compact", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.prompt("one"); + + setStreaming(harness, true); + const result = harness.session.handleCompactHostRequest("compact.run"); + setStreaming(harness, false); + expect(result).toEqual({ scheduled: false, reason: "session is too short to compact" }); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + }); + + it("runs a requested compaction even when auto-compaction is disabled", async () => { + const harness = await createHarness({ + settings: { compaction: { enabled: false, keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect(harness.session.handleCompactHostRequest("compact.run").scheduled).toBe(true); + setStreaming(harness, false); + + const internals = harness.session as unknown as SessionInternals; + expect(internals._shouldStopAfterTurn({ message: createAssistant(harness) } as ShouldStopAfterTurnContext)).toBe( + true, + ); + await internals._checkCompaction(createAssistant(harness)); + + const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + expect(compactionEntries).toHaveLength(1); + }); + + it("drops a pending requested compaction when the turn is aborted", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect(harness.session.handleCompactHostRequest("compact.run").scheduled).toBe(true); + setStreaming(harness, false); + + const internals = harness.session as unknown as SessionInternals; + const compacted = await internals._checkCompaction(createAssistant(harness, { stopReason: "aborted" })); + expect(compacted).toBe(false); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0); + }); + + it("drops a pending requested compaction on the pre-prompt path after an abort", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect(harness.session.handleCompactHostRequest("compact.run").scheduled).toBe(true); + setStreaming(harness, false); + + const internals = harness.session as unknown as SessionInternals; + const compacted = await internals._checkCompaction(createAssistant(harness, { stopReason: "aborted" }), false); + expect(compacted).toBe(false); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0); + }); + + it("rejects compact.run while no turn is active", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + const result = harness.session.handleCompactHostRequest("compact.run"); + expect(result.scheduled).toBe(false); + expect(result.reason).toContain("no active turn"); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + }); + + it("prioritizes overflow recovery over a pending requested compaction", async () => { + vi.useFakeTimers(); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect( + harness.session.handleCompactHostRequest("compact.run", { instructions: "keep the todo list" }).scheduled, + ).toBe(true); + setStreaming(harness, false); + + const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + const internals = harness.session as unknown as SessionInternals; + const overflow = createAssistant(harness, { stopReason: "error", errorMessage: "prompt is too long" }); + const compacted = await internals._checkCompaction(overflow); + await vi.advanceTimersByTimeAsync(100); + vi.useRealTimers(); + + expect(compacted).toBe(true); // willRetry + expect(harness.eventsOfType("compaction_start").at(-1)).toMatchObject({ + reason: "overflow", + customInstructions: "keep the todo list", + }); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + expect(continueSpy).toHaveBeenCalled(); + }); + + it("resumes the loop when a requested compaction is skipped", async () => { + vi.useFakeTimers(); + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.prompt("one"); + + (harness.session as unknown as { _pendingRequestedCompaction?: object })._pendingRequestedCompaction = {}; + harness.session.agent.followUp({ + role: "custom", + customType: "test", + content: [{ type: "text", text: "queued" }], + display: false, + timestamp: Date.now(), + }); + const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + + const internals = harness.session as unknown as SessionInternals; + const compacted = await internals._checkCompaction(createAssistant(harness)); + await vi.advanceTimersByTimeAsync(100); + vi.useRealTimers(); + + expect(compacted).toBe(false); + expect(harness.eventsOfType("compaction_end").at(-1)?.errorMessage).toContain("Requested compaction skipped"); + expect(continueSpy).toHaveBeenCalledTimes(1); + }); + + it("keeps a pending requested compaction when manual compaction fails", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi: any) => { + pi.on("session_before_compact", async () => ({ cancel: true })); + }, + ], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect(harness.session.handleCompactHostRequest("compact.run").scheduled).toBe(true); + setStreaming(harness, false); + + await expect(harness.session.compact()).rejects.toThrow("Compaction cancelled"); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(true); + }); + + it("clears a pending requested compaction when manual compaction succeeds", async () => { + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [extensionCompaction()], + }); + harnesses.push(harness); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + + setStreaming(harness, true); + expect(harness.session.handleCompactHostRequest("compact.run").scheduled).toBe(true); + setStreaming(harness, false); + + await harness.session.compact(); + expect(harness.session.handleCompactHostRequest("compact.status").scheduled).toBe(false); + }); + + it("reports context usage via compact.status", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.prompt("one"); + + const status = harness.session.handleCompactHostRequest("compact.status"); + expect(status.scheduled).toBe(false); + expect(status.context_window).not.toBeNull(); + }); + + it("is gated by the compaction.agentCallable setting", async () => { + const harness = await createHarness({ settings: { compaction: { agentCallable: false } } }); + harnesses.push(harness); + + expect(() => harness.session.handleCompactHostRequest("compact.run")).toThrow( + "the compact skill is disabled in this session", + ); + const internals = harness.session as unknown as SessionInternals; + expect(Object.keys(internals._createKernelHostHandlers())).not.toContain("compact.run"); + + const enabledHarness = await createHarness(); + harnesses.push(enabledHarness); + const enabledInternals = enabledHarness.session as unknown as SessionInternals; + expect(Object.keys(enabledInternals._createKernelHostHandlers())).toEqual( + expect.arrayContaining(["compact.run", "compact.status"]), + ); + }); +});