diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 991ad5b43a..2390833bff 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -27,6 +27,8 @@ function expectedFailure(error: ManualCompactionError): CommandResult { kind: 'error', text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.', } + case 'cancelled': + return { kind: 'error', text: 'Compaction cancelled.' } case 'changed': return { kind: 'error', diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 2251aec7f1..4eba08fa40 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -365,44 +365,54 @@ export class BasicCompactService extends CompactService { * Force one useful idle-session compaction below the pressure threshold, and * resolve only after its standalone marker pair is durably checkpointed. * @param agent - idle agent whose next-turn admission this call reserves. - * @param signal - command-owned cancellation forwarded to summarization. + * @param signal - cancellation scoped to this compaction request. * @returns the committed result, or `null` when no safe useful range exists. */ - override async compactNow( - agent: Agent, - signal: AbortSignal, - ): Promise { + override compactNow(agent: Agent, signal: AbortSignal): Promise { signal.throwIfAborted() - const releaseTurnAdmission = agent.reserveTurnAdmission() - if (releaseTurnAdmission === undefined) { + try { + return agent.runMaintenance(async (agentSignal) => { + const operationSignal = AbortSignal.any([agentSignal, signal]) + try { + operationSignal.throwIfAborted() + const range = selectCompactableRange( + agent.session, + this.ctx.tokenMeter.measure(agent.session), + 0, + ) + if (range === null) return null + return await compactSurfaceRegion( + this.regionDependencies(), + agent.session, + range.start, + range.end, + agent, + { + owner: null, + stability: 'selected-span', + flush: () => this.ctx.sessions.flush(agent.session), + }, + operationSignal, + ) + } catch (error: unknown) { + if (agentSignal.aborted && operationSignal.reason === agentSignal.reason) { + throw new ManualCompactionError( + 'cancelled', + 'manual compaction was cancelled', + { cause: error }, + ) + } + operationSignal.throwIfAborted() + throw error + } + }) + } catch (error: unknown) { throw new ManualCompactionError( 'busy', 'manual compaction requires an idle agent with no waking queued work', + { cause: error }, ) } - try { - const range = selectCompactableRange( - agent.session, - this.ctx.tokenMeter.measure(agent.session), - 0, - ) - if (range === null) return null - return await compactSurfaceRegion( - this.regionDependencies(), - agent.session, - range.start, - range.end, - agent, - { - owner: null, - stability: 'selected-span', - flush: () => this.ctx.sessions.flush(agent.session), - }, - signal, - ) - } finally { - releaseTurnAdmission() - } } /** Bind the effective token meter and dynamically dispatched summarizer hook. */ diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index ecc00597b2..e537639adc 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -22,7 +22,13 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi export type CompactionTrigger = 'pressure' | 'context-overflow' /** Expected failure classes for an explicit idle-session compaction request. */ -export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +export type ManualCompactionErrorCode = + | 'busy' + | 'cancelled' + | 'changed' + | 'summary' + | 'commit' + | 'persistence' /** * Expected manual-compaction failure suitable for a direct human-command result. @@ -59,7 +65,14 @@ export interface CompactAgentContext { * other compaction transactions. */ export interface ManualCompactAgentContext extends CompactAgentContext { - reserveTurnAdmission(): (() => void) | undefined + /** + * Run a non-turn maintenance operation only while the agent is idle, withholding later + * waking input until it settles. + * @param task - operation whose fulfillment or rejection is preserved, with an agent-owned cancellation signal. + * @throws synchronously when the agent is already active. + * @returns the task promise. + */ + runMaintenance(task: (signal: AbortSignal) => Promise): Promise } declare module 'cordis' { @@ -102,21 +115,22 @@ export abstract class CompactService extends Service { /** * Explicitly compact useful history even below automatic pressure thresholds. - * Implementations reserve idle turn admission synchronously before any - * asynchronous work, select a useful range without writing on a no-op, then + * Implementations synchronously start an idle task before any asynchronous + * work, select a useful range without writing on a no-op, then * append a standalone `compact/start` before summarization. That durable * marker is the compaction lock until one `compact/end` attempt. Later waking * prompts remain accepted in FIFO order and start only after the optional - * durability checkpoint and admission release. Context injected while the + * durability checkpoint and idle-task settlement. Context injected while the * summary runs may sit between the marker pair; only the selected span must * remain stable. * * @param agent - idle agent whose durable history should be compacted. - * @param signal - command-owned cancellation forwarded to summarization. + * @param signal - cancellation scoped to this compaction request. * @returns the compaction result, or `null` when no safe useful range exists. - * @throws {@link ManualCompactionError} for expected busy, changed-span, - * summarization/shrink, commit-stage, or persistence failures, and the exact - * abort reason when cancelled. Failed attempts remain visible in the log. + * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, + * changed-span, summarization/shrink, commit-stage, or persistence failures; + * an aborted request preserves its exact abort reason. Failed attempts remain + * visible in the log. */ abstract compactNow( agent: ManualCompactAgentContext, diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 70bdfc7744..6d6a5f67d5 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -35,6 +35,12 @@ import { executeToolCalls } from './tool-calls.ts' type Phase = | { kind: 'idle'; lastTurn: number } + | { + kind: 'maintenance' + abort: AbortController + lastTurn: number + wakeRequested: boolean + } | { kind: 'collecting'; abort: AbortController; lastTurn: number } | { kind: 'running'; abort: AbortController; turn: number; step: number } @@ -57,7 +63,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig { export class ReactLoopAgent implements Agent { readonly inbox: Inbox private phase: Phase - private driverDone: Promise = Promise.resolve() + private activityDone: Promise = Promise.resolve() /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */ readonly scope: Scope @@ -85,7 +91,7 @@ export class ReactLoopAgent implements Agent { } get status(): AgentStatus { - return this.phase.kind === 'idle' ? 'idle' : 'running' + return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } /** Commit a phase and publish its externally visible status transition. */ @@ -99,7 +105,7 @@ export class ReactLoopAgent implements Agent { } send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { - // Waking input cannot join an aborted pre-step or turn, so it starts the next turn. + // Waking input cannot join an aborted activity, so it starts the next turn. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) @@ -119,15 +125,44 @@ export class ReactLoopAgent implements Agent { } cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { - if (!options.keepInbox) this.inbox.clear() + if (!options.keepInbox) { + this.inbox.clear() + if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } - /** Reserve a driver before deferring idle pre-step processing. */ + runMaintenance(task: (signal: AbortSignal) => Promise): Promise { + if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`) + const done = Promise.withResolvers() + const maintenance: Phase = { + kind: 'maintenance', + abort: new AbortController(), + lastTurn: this.phase.lastTurn, + wakeRequested: false, + } + this.setPhase(maintenance) + this.activityDone = done.promise + return (async () => { + try { + return await task(maintenance.abort.signal) + } finally { + this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) + if (maintenance.wakeRequested) this.scheduleKick() + done.resolve() + } + })() + } + + /** Schedule one driver, or remember its wake behind maintenance. */ private scheduleKick(): void { + if (this.phase.kind === 'maintenance') { + if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true + return + } if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() - this.driverDone = driver.promise + this.activityDone = driver.promise this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn }) queueMicrotask(() => { this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) @@ -135,10 +170,10 @@ export class ReactLoopAgent implements Agent { } async whenIdle(): Promise { - let driver: Promise + let activity: Promise do { - await (driver = this.driverDone) - } while (driver !== this.driverDone) + await (activity = this.activityDone) + } while (activity !== this.activityDone) } /** Report one failure at its live boundary, then preserve it for driver containment. */ @@ -184,7 +219,7 @@ export class ReactLoopAgent implements Agent { /** Claimed input stays unowned until `turn/start` commits. */ private async turn(): Promise { - if (this.phase.kind === 'idle') { + if (this.phase.kind === 'idle' || this.phase.kind === 'maintenance') { this.throwError(new Error(`agent "${this.id}": turn without driver reservation`)) } const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController() diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fd33c256d1..0f1f10b621 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -100,9 +100,9 @@ export interface Agent { /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. The first cause wins for the active turn. Idle cancellation is a - * no-op and does not arm later work. - * @param cause - the stable caller intent carried by the current turn signal. + * turn or between-turn task. The first cause wins for that activity. With no + * active activity, cancellation is a no-op and does not arm later work. + * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void @@ -115,6 +115,17 @@ export interface Agent { */ whenIdle(): Promise + /** + * Run one non-turn maintenance task from the true idle phase. The task starts + * synchronously after claiming that phase; later waking input remains in the + * inbox until the task settles, while public status stays `idle`. + * `whenIdle()` follows both the task and any waking work released behind it. + * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. + * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @returns the task promise. + */ + runMaintenance(task: (signal: AbortSignal) => Promise): Promise + /** * Route identified input to an inbox boundary and optionally wake the driver. * Waking input submitted after active cancellation is queued for the next turn. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4c7b33cc38..51c59c5ddd 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -973,6 +973,19 @@ export function createTuiChat( void shutdown(true) } + /** Cancel the active turn or standalone compaction, preserving work queued behind compaction. */ + const cancelActive = (): boolean => { + if (agent.status === 'running') { + agent.cancel({ kind: 'user' }) + return true + } + if (compacting !== undefined) { + agent.cancel({ kind: 'user' }, { keepInbox: true }) + return true + } + return false + } + /** Swap the palette and all derived themes for the given terminal color scheme. */ const applyColorScheme = (scheme: TerminalColorScheme): void => { if (scheme === currentScheme) return @@ -1033,8 +1046,8 @@ export function createTuiChat( chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', - 'Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L redraw', - 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', + 'Esc cancel active work • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L redraw', + 'Ctrl+C cancel active work; clear input or exit while idle • Ctrl+D exit', '', ...commandLines, '/skill: [instructions] — load a skill into the conversation', @@ -1518,14 +1531,12 @@ export function createTuiChat( ui.requestRender(true) return { consume: true } } - if (matchesKey(data, Key.escape) && agent.status === 'running') { - agent.cancel({ kind: 'user' }) + if (matchesKey(data, Key.escape) && cancelActive()) { return { consume: true } } if (matchesKey(data, Key.ctrl('c'))) { - if (agent.status === 'running') { - agent.cancel({ kind: 'user' }) - } else if (editor.getText() !== '') { + if (cancelActive()) return { consume: true } + if (editor.getText() !== '') { editor.setText('') } else { requestExit() @@ -1533,7 +1544,9 @@ export function createTuiChat( return { consume: true } } if (matchesKey(data, Key.ctrl('d'))) { - if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning') + if (agent.status === 'running' || compacting !== undefined) { + appendNotice('Cancel active work before exiting.', 'warning') + } else requestExit() return { consume: true } }