mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(agent): run maintenance between turns
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<CompactionResult | null> {
|
||||
override compactNow(agent: Agent, signal: AbortSignal): Promise<CompactionResult | null> {
|
||||
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. */
|
||||
|
||||
@@ -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<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void> = Promise.resolve()
|
||||
private activityDone: Promise<void> = 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<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
|
||||
const done = Promise.withResolvers<void>()
|
||||
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<void>()
|
||||
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<void> {
|
||||
let driver: Promise<void>
|
||||
let activity: Promise<void>
|
||||
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<boolean> {
|
||||
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()
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
/**
|
||||
* 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<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
|
||||
/**
|
||||
* Route identified input to an inbox boundary and optionally wake the driver.
|
||||
* Waking input submitted after active cancellation is queued for the next turn.
|
||||
|
||||
@@ -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:<name> [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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user