feat(agent-loop): turn-enclosure invariant + post-turn error model
Every session event now lives inside a turn (between turn/start and its turn/end). The loop records queued user/message events AFTER turn/start; an idle agent.inject() wraps its context/message in a one-shot injection turn. This makes the turn the single durability/replay boundary so a persistence backend can treat anything after the last turn/end as a crash tail without dropping legitimate between-turn context. A failure once the turn is already closed (rejecting session/flush, a throwing agent/turn-end listener) has no in-turn position for a session error event, so it is reported via agent/error + logger only; the turn stays balanced. failTurn appends an error event only while the turn is open. The dsh-invariants plugin enforces turn-enclosure via a default case: every non-boundary event type — including plugin-added merge-extensible keys — must sit inside an open turn or it throws. Documented in ADR 0017 + architecture.md.
This commit is contained in:
@@ -12,7 +12,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
import { runLoop } from './loop.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
@@ -73,7 +73,52 @@ export class LoopAgent implements Agent {
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
this.session.append('context/message', { content, source: this.resolveSource(options) })
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', { content, source })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is OWED no matter what — even
|
||||
// if a throwing `session/event` listener escapes from the turn/start append
|
||||
// (Session.append pushes the event BEFORE notifying listeners) or the
|
||||
// context/message append throws (non-serializable content, throwing
|
||||
// listener). The finally re-checks the log via isTurnOpen() and closes the
|
||||
// turn if one was actually opened, so the log never carries a permanently
|
||||
// open injection turn that would corrupt later turns/replay. (If the
|
||||
// turn/start append throws BEFORE pushing — non-serializable trigger, which
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
let turnRecorded = false
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source })
|
||||
} finally {
|
||||
// A turn was recorded iff turn/start made it into the log. Close it and
|
||||
// mark it for the durability checkpoint below — which must run even when
|
||||
// an append's listener threw (the turn is balanced and in memory, so it
|
||||
// still needs a flush or a crash before the next turn/dispose loses it).
|
||||
if (isTurnOpen(this.session)) {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
turnRecorded = true
|
||||
}
|
||||
// Checkpoint the one-shot turn for durability, exactly as the loop does at
|
||||
// every turn/end. The loop is NOT running (we are idle), so nothing else
|
||||
// will flush this turn. Fire-and-forget with error containment: inject()
|
||||
// is synchronous, and a persistence backend failing must not throw into
|
||||
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
|
||||
// independently, so a slow flush is safe. In the finally so it also runs
|
||||
// when an append's listener threw (the turn is still balanced + durable).
|
||||
if (turnRecorded) {
|
||||
void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abort(reason?: string): void {
|
||||
|
||||
Reference in New Issue
Block a user