refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-persistence/session-checkpoint-policy/README.md
|
||||
README.md: 01ed3c694967b1a86245d01b9d9f7eecd1193348
|
||||
README.zh.md: e2c197c0cc1d942a045c1ea22c1fb257f6e4059d
|
||||
45
packages/session/session-checkpoint-policy/README.md
Normal file
45
packages/session/session-checkpoint-policy/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# dsh-session-checkpoint-policy
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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/pre-step` boundary so the preceding response and ordered tool results are durable before the next request.
|
||||
|
||||
## Plugin (namespace: `session-checkpoint-policy`)
|
||||
|
||||
This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`, and the presence of `ctx.sessionPersistence`. Load it beside one persistence backend:
|
||||
|
||||
```yaml
|
||||
- id: session-persistence
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
```
|
||||
|
||||
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend starts bounded background batches for `session/event` appends and makes each requested `session/flush` an immediate quiescence 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 events still inside the configured batching window or an outstanding write. 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/pre-step` persists the preceding response/result batch before request derivation.
|
||||
|
||||
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
|
||||
|
||||
### Interrupted calls
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The plugin adds no prompt or tool schema. A hard crash after a tool checkpoint but before its result leaves a durable unmatched call; session recovery supplies the model-visible `TOOL_OUTCOME_UNKNOWN` result owned by `dsh-session`. The message permits retry for read-only or idempotent work and requires state verification or user confirmation for calls that may have side effects.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Successful checkpoints add no tokens and do not change the request. Recovery adds one short tool-result message to balance the interrupted transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The repair result is appended after the reusable prefix, so it does not invalidate earlier cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
|
||||
- Streaming `assistant/chunk` events have no per-chunk checkpoint. Bounded background batches normally persist them before the next semantic checkpoint, but a hard crash may lose the current in-memory batch or outstanding write.
|
||||
- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically.
|
||||
45
packages/session/session-checkpoint-policy/README.zh.md
Normal file
45
packages/session/session-checkpoint-policy/README.zh.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# dsh-session-checkpoint-policy
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/pre-step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。
|
||||
|
||||
## 插件(命名空间:`session-checkpoint-policy`)
|
||||
|
||||
该零配置函数插件消费 `ctx.sessions`、`ctx.llm`、`ctx.tools` 以及 `ctx.sessionPersistence` 的存在性。将其与一个持久化后端一起加载:
|
||||
|
||||
```yaml
|
||||
- id: session-persistence
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
```
|
||||
|
||||
持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会为追加的 `session/event` 启动有界后台批次,并把每个已请求的 `session/flush` 变成即时完全停稳屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失仍位于已配置批处理窗口内的事件,或尚未完成的写入。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。
|
||||
|
||||
策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/pre-step` 在派生请求前持久化前一响应/结果批次。
|
||||
|
||||
在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。步骤边界处的检查点被拒绝会在另一个请求开始前使轮次失败。并发工具检查点共享会话存储的串行持久化排空流程,不会产生重复的序列号。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 中断调用
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
插件不添加提示词或工具 schema。工具检查点后、结果前的硬崩溃会留下持久的未匹配调用;会话恢复会提供模型可见的 `TOOL_OUTCOME_UNKNOWN` 结果,该结果由 `dsh-session` 负责。该消息允许重试只读或幂等工作,并要求对可能有副作用的调用验证状态或请求用户确认。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
成功检查点不添加 token,也不改变请求。恢复会添加一条短工具结果消息,以平衡中断的 transcript(文本记录)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
修复结果追加在可重用前缀之后,因此不会使较早的缓存条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 该策略以持久方式记录执行意图,而非为通用副作用提供恰好一次保证。当提供方支持时,有副作用的工具应将 `exec.callId` 作为幂等键转发。
|
||||
- 流式 `assistant/chunk` 事件没有逐分片检查点。有界后台批次通常会在下一个语义检查点之前将其持久化,但硬崩溃可能丢失当前内存批次或尚未完成的写入。
|
||||
- 已持久化的调用没有结果时,无法证明其外部副作用是否完成。因此,恢复会记录未知结果,而不是自动重试。
|
||||
50
packages/session/session-checkpoint-policy/package.json
Normal file
50
packages/session/session-checkpoint-policy/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-checkpoint-policy",
|
||||
"description": "Semantic session durability checkpoints before model requests and tool side effects",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
83
packages/session/session-checkpoint-policy/src/index.ts
Normal file
83
packages/session/session-checkpoint-policy/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Semantic durability checkpoints for model requests, top-level tool dispatch,
|
||||
* and completed agent steps.
|
||||
* @module @deepseek-ai/dsh-session-checkpoint-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** Cordis plugin name used by Loader diagnostics. */
|
||||
export const name = 'session-checkpoint-policy'
|
||||
|
||||
/** Services whose request, tool, session, and persistence boundaries this policy joins. */
|
||||
export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools']
|
||||
|
||||
/**
|
||||
* Delay construction of the downstream model stream until the complete logged
|
||||
* request prefix is durable. A checkpoint rejection prevents adapter dispatch.
|
||||
*
|
||||
* @param ctx - plugin context that owns the session store.
|
||||
* @param session - live session named by the model request.
|
||||
* @param next - downstream `llm/stream` chain.
|
||||
* @returns a stream that checkpoints before requesting its first chunk.
|
||||
*/
|
||||
function afterCheckpoint(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
next: () => AsyncIterable<StreamChunk>,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
return (async function* (): AsyncIterable<StreamChunk> {
|
||||
await ctx.sessions.flush(session)
|
||||
yield* next()
|
||||
})()
|
||||
}
|
||||
|
||||
/** Materialize the canonical result for a call cancelled before tool dispatch. */
|
||||
function abortedBeforeDispatchResult(): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
|
||||
* logged request before adapter dispatch; top-level tool calls checkpoint their
|
||||
* recorded call before the tool body; the next request boundary checkpoints
|
||||
* the preceding response/result batch. Nested tool dispatches reuse the durable outer call.
|
||||
*
|
||||
* Checkpoint failures are fail-closed at the model and tool side-effect
|
||||
* boundaries: the downstream adapter or tool body is not invoked.
|
||||
*
|
||||
* @param ctx - plugin context that owns the listeners.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.on('llm/stream', (options, next): AsyncIterable<StreamChunk> => {
|
||||
if (options.sessionId === undefined) return next()
|
||||
const session = ctx.sessions.get(options.sessionId)
|
||||
return session === undefined ? next() : afterCheckpoint(ctx, session, next)
|
||||
})
|
||||
|
||||
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
|
||||
if (exec.agent === undefined || exec.parent !== undefined) return next()
|
||||
await ctx.sessions.flush(exec.agent.session)
|
||||
if (exec.signal.aborted) return abortedBeforeDispatchResult()
|
||||
return next()
|
||||
})
|
||||
|
||||
// Before each request, persist everything committed by the preceding step;
|
||||
// the first step's call is an intentional no-op beyond any prompt intake.
|
||||
ctx.on('agent/pre-step', async ({ agent }, next): Promise<PreStepDecision> => {
|
||||
await ctx.sessions.flush(agent.session)
|
||||
return next()
|
||||
})
|
||||
}
|
||||
30
packages/session/session-checkpoint-policy/src/invariant.ts
Normal file
30
packages/session/session-checkpoint-policy/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`.
|
||||
* @module @deepseek-ai/dsh-session-checkpoint-policy/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-checkpoint-policy-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and
|
||||
* persistence seams; this stateless policy owns no independent mutable relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import SessionStore, {
|
||||
SessionId, TOOL_OUTCOME_UNKNOWN,
|
||||
type SessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const sessionId = SessionId('semantic-checkpoint-crash')
|
||||
const roots: string[] = []
|
||||
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
||||
|
||||
async function waitForMarker(path: string, expected: string): Promise<string> {
|
||||
// vi.waitFor retries every callback throw, so terminal states RESOLVE out
|
||||
// of the retry loop (complete marker, or content that can no longer become
|
||||
// the expected marker) and only the still-in-progress states throw-to-retry.
|
||||
const content = await vi.waitFor(async () => {
|
||||
const current = await readFile(path, 'utf8').catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
|
||||
})
|
||||
if (current === expected || !expected.startsWith(current)) return current
|
||||
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
|
||||
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
|
||||
if (content !== expected) {
|
||||
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
|
||||
roots.push(root)
|
||||
const marker = join(root, 'failpoint')
|
||||
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
|
||||
await writeFile(marker, '')
|
||||
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
|
||||
// The SIGKILL-at-failpoint choreography stays custom: the child must die
|
||||
// mid-write, so no timeout or graceful termination may reach it first.
|
||||
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
cwd: repoRoot,
|
||||
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdin: 'ignore',
|
||||
stdout: 'ignore',
|
||||
reject: false,
|
||||
})
|
||||
try {
|
||||
const markerText = await waitForMarker(marker, expectedMarker)
|
||||
child.kill('SIGKILL')
|
||||
const exit = await child
|
||||
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
return { root, markerText }
|
||||
} catch (error: unknown) {
|
||||
child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function load(root: string): Promise<SessionEvent[]> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
try {
|
||||
return [...(await ctx.sessionPersistence.load(sessionId)).events]
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => {
|
||||
it('persists the complete request before model dispatch', async () => {
|
||||
const crashed = await crashAt('request')
|
||||
expect(crashed.markerText).toBe('request-dispatched')
|
||||
const events = await load(crashed.root)
|
||||
expect(events.map(event => event.type)).toEqual([
|
||||
'agent/inbox/spliced', 'turn/start', 'agent/inbox/spliced',
|
||||
'step/start', 'user/message', 'request/header', 'request/context', 'step/end', 'turn/end',
|
||||
])
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('persists tool intent before a side effect and repairs its missing result as unknown', async () => {
|
||||
const crashed = await crashAt('tool')
|
||||
expect(crashed.markerText).toBe('tool-side-effect')
|
||||
const events = await load(crashed.root)
|
||||
expect(events.some(event => event.type === 'assistant/message')).toBe(true)
|
||||
expect(events.some(event => event.type === 'tool/call')).toBe(true)
|
||||
const result = events.find(event => event.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (result?.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(result.data.message.content[0].content[0].text).toContain('Do not retry blindly.')
|
||||
})
|
||||
})
|
||||
60
packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
vendored
Normal file
60
packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { Context } from 'cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as checkpointPolicy from '../../src/index.ts'
|
||||
|
||||
function waitForCrash(): Promise<never> {
|
||||
return new Promise(() => { setInterval(() => {}, 60_000) })
|
||||
}
|
||||
|
||||
const [mode, root, marker] = process.argv.slice(2)
|
||||
if ((mode !== 'request' && mode !== 'tool') || root === undefined || marker === undefined) {
|
||||
throw new Error('usage: crash-child.ts <request|tool> <persistence-root> <marker>')
|
||||
}
|
||||
const persistenceRoot = root
|
||||
const failpoint = marker
|
||||
|
||||
class CrashAdapter extends LlmAdapter {
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (mode === 'request') {
|
||||
await writeFile(failpoint, 'request-dispatched')
|
||||
await waitForCrash()
|
||||
return
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: CallId('crash-call'), name: 'crash_tool', arguments: '{}' },
|
||||
}
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' })
|
||||
await ctx.plugin(checkpointPolicy)
|
||||
ctx.llm.registerAdapter(['crash'], new CrashAdapter())
|
||||
ctx.tools.register({
|
||||
name: 'crash_tool',
|
||||
description: 'records an external effect and never returns',
|
||||
parameters: {},
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute() {
|
||||
await writeFile(failpoint, 'tool-side-effect')
|
||||
return waitForCrash()
|
||||
},
|
||||
})
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('semantic-checkpoint-crash'),
|
||||
agentOptions: { provider: 'crash', model: 'crash' },
|
||||
})
|
||||
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'exercise the crash boundary' }], source: { kind: 'user' } }))
|
||||
await waitForCrash()
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import * as checkpointPolicy from '../src/index.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
locate(_meta: SessionHeader): undefined { return undefined }
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
listSnapshots(): Promise<never[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
constructor(private readonly order: string[]) { super() }
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.order.push('adapter')
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TestPersistence)
|
||||
await ctx.plugin(checkpointPolicy)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function drain(stream: AsyncIterable<StreamChunk>): Promise<void> {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy request boundary', () => {
|
||||
it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('request-checkpoint'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
|
||||
const pending = drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
|
||||
}))
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
gate.resolve(undefined)
|
||||
await pending
|
||||
expect(order).toEqual(['flush:start', 'flush:end', 'adapter'])
|
||||
})
|
||||
|
||||
it('delegates a request without a live session without checkpointing', async () => {
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] }))
|
||||
expect(order).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
it('delegates an already-detached session id without checkpointing', async () => {
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'),
|
||||
}))
|
||||
expect(order).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
it('does not dispatch the adapter when the checkpoint rejects', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('request-failure'))
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await expect(drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
|
||||
}))).rejects.toThrow('disk unavailable')
|
||||
expect(order).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy tool and step boundaries', () => {
|
||||
it('awaits the checkpoint before a top-level tool body', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-checkpoint'))
|
||||
const agent = { session } as Agent
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
execute: async () => { order.push('tool'); return null },
|
||||
})
|
||||
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('write-1'), name: 'write', arguments: {}, agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
gate.resolve(undefined)
|
||||
await expect(pending).resolves.toMatchObject({ isError: false })
|
||||
expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
|
||||
})
|
||||
|
||||
it('does not dispatch when cancellation lands during the tool checkpoint', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel'))
|
||||
const agent = { session } as Agent
|
||||
const controller = new AbortController()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
execute: async () => { order.push('tool'); return null },
|
||||
})
|
||||
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
controller.abort('cancelled during checkpoint')
|
||||
gate.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
},
|
||||
})
|
||||
expect(order).toEqual(['flush:start', 'flush:end'])
|
||||
})
|
||||
|
||||
it('turns a rejected checkpoint into an error result without running the tool body', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-failure'))
|
||||
const agent = { session } as Agent
|
||||
let ran = false
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
execute: async () => { ran = true; return null },
|
||||
})
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('write-2'), name: 'write', arguments: {}, agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }])
|
||||
expect(ran).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses the outer checkpoint for a nested tool dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('nested-tool'))
|
||||
const agent = { session } as Agent
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
ctx.tools.register({
|
||||
name: 'nested', description: 'nested', parameters: {},
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
execute: async () => null,
|
||||
})
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('nested-1'), name: 'nested', arguments: {}, agent,
|
||||
parent: Symbol('outer') as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('checkpoints during pre-step processing', 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) })
|
||||
const signal = new AbortController().signal
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter', messages: [] }),
|
||||
)
|
||||
expect(flushed).toEqual([session.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy lifecycle', () => {
|
||||
it('removes its wrappers when the owning fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TestPersistence)
|
||||
const session = ctx.sessions.create(SessionId('disposed-policy'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter([]))
|
||||
const fiber = await ctx.plugin(checkpointPolicy)
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
|
||||
expect(flushes).toBe(1)
|
||||
await fiber.dispose()
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
|
||||
expect(flushes).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the Loader-safe namespace plugin shape', () => {
|
||||
expect('default' in checkpointPolicy).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(checkpointPolicy) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(checkpointPolicy)
|
||||
expect(unwrapped.name).toBe('session-checkpoint-policy')
|
||||
expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
36
packages/session/session-checkpoint-policy/tsconfig.json
Normal file
36
packages/session/session-checkpoint-policy/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user