refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}`)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user