refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -1,6 +1,6 @@
# dsh-session-checkpoint-policy
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`.
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/step` boundary so the preceding response and ordered tool results are durable before the next request.
## Plugin (namespace: `session-checkpoint-policy`)
@@ -14,13 +14,11 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend eagerly writes `session/event` appends and makes each requested `session/flush` an observation barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose the latest eagerly buffered events. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/step` persists the preceding response/result batch before request derivation.
The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A post-step rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A step-boundary rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
## Model Experience

View File

@@ -217,15 +217,13 @@ describe('session-checkpoint-policy tool and step boundaries', () => {
expect(flushes).toBe(0)
})
it('checkpoints the complete recorded step at agent/post-step', async () => {
it('checkpoints before the next agent step', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('post-step'))
const agent = { session } as Agent
const flushed: string[] = []
ctx.on('session/flush', (current) => { flushed.push(current.id) })
await agentEvents(ctx, agent).serial(
'agent/post-step', 1, 1, new AbortController().signal,
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
expect(flushed).toEqual([session.id])
})
})

View File

@@ -154,6 +154,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private states = new Map<SessionId, SessionState>()
/** Lifecycle and write-behind state keyed by the exact live Session. */
private live = new Map<Session, LiveSessionState>()
/** Exact disposed lifecycles whose eager tail is still draining. */
private retirements = new Map<SessionId, Promise<void>>()
/** Cold loads currently reserving an id across backend reads and repair writes. */
private coldLoads = new Set<SessionId>()
/**
@@ -250,6 +252,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns the header plus the event log, ending on a balanced `turn/end`.
*/
async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
await this.retirements.get(id)
const selected = await this.serialize(id, async () => {
const live = this.ctx.sessions.get(id)
if (live !== undefined) return { live }
@@ -270,7 +273,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns stored header and events before any synthetic recovery closers.
*/
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id))
return Promise.resolve(this.retirements.get(id))
.then(() => this.serialize(id, () => this.inspectCore(id)))
}
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
@@ -432,7 +436,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** Start and observe one disposed session's final drain. */
private retire(session: Session): void {
if (!this.live.has(session)) return
void this.retireCore(session).catch((error: unknown) => {
const retirement = this.retireCore(session)
this.retirements.set(session.id, retirement)
const forget = (): void => {
if (this.retirements.get(session.id) === retirement) this.retirements.delete(session.id)
}
void retirement.then(forget, forget)
void retirement.catch((error: unknown) => {
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`)
})
}