Merge branch 'master' into worktree/routed-model-compaction-policy
# Conflicts: # docs/event-producer-consumer.md
This commit is contained in:
@@ -15,7 +15,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
|
||||
- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
|
||||
- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits.
|
||||
- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal.
|
||||
- **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal.
|
||||
- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md).
|
||||
|
||||
Naming notes:
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -31,6 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
30
packages/bash/bash-local/src/invariant.ts
Normal file
30
packages/bash/bash-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-local`.
|
||||
* @module @deepseek-ai/dsh-bash-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -31,10 +37,11 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"node-addon-landlock-run": "0.0.0-test.0"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/bash/bash-sandbox/src/invariant.ts
Normal file
30
packages/bash/bash-sandbox/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-sandbox`.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-sandbox-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,21 +11,28 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
22
packages/bash/bash/src/invariant.ts
Normal file
22
packages/bash/bash/src/invariant.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-bash/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless seam owns request/result types, while executors and policy own observations. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register the bash 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))
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -25,9 +30,10 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-home": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
@@ -42,10 +48,10 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-home": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
30
packages/bash/tool-bash/src/invariant.ts
Normal file
30
packages/bash/tool-bash/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`.
|
||||
* @module @deepseek-ai/dsh-tool-bash/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-bash-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the environment registry validates ownership and collected values at each
|
||||
* mutation/read; it publishes no independent snapshot that a companion could cross-check.
|
||||
*/
|
||||
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 */
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./lib/types/worker.d.ts",
|
||||
"default": "./lib/worker.cjs"
|
||||
@@ -19,6 +23,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/worker.cjs",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
@@ -27,6 +32,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/code-runtime/code-runtime-worker/src/invariant.ts
Normal file
30
packages/code-runtime/code-runtime-worker/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-worker`.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'code-runtime-worker-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this process-boundary implementation exposes no same-process event relation;
|
||||
* worker protocol and built-worker tests cover it.
|
||||
*/
|
||||
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 */
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../code-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown'
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -11,20 +11,27 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/code-runtime/code-runtime/src/invariant.ts
Normal file
30
packages/code-runtime/code-runtime/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime`.
|
||||
* @module @deepseek-ai/dsh-code-runtime/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'code-runtime-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
@@ -84,4 +84,5 @@ describe('CodeRuntime service seam', () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^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-token-meter": "^0.0.1",
|
||||
|
||||
30
packages/compact/compact-basic/src/invariant.ts
Normal file
30
packages/compact/compact-basic/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-compact-basic`.
|
||||
* @module @deepseek-ai/dsh-compact-basic/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'compact-basic-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
@@ -20,8 +20,8 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const SIGNAL = new AbortController().signal
|
||||
const MODEL = 'test-model'
|
||||
@@ -406,6 +406,7 @@ describe('compact configuration and defaults', () => {
|
||||
expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/)
|
||||
expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('pressure measurement and retention', () => {
|
||||
@@ -1142,7 +1143,7 @@ describe('default one-shot summarizer', () => {
|
||||
|
||||
describe('automatic listener and loader composition', () => {
|
||||
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
|
||||
return ctx.serial('agent/post-step', owner, 1, 1, signal)
|
||||
return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
|
||||
}
|
||||
|
||||
function recover(
|
||||
@@ -1155,7 +1156,9 @@ describe('automatic listener and loader composition', () => {
|
||||
): Promise<{ action: 'fail' | 'retry' }> {
|
||||
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
|
||||
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
|
||||
return ctx.waterfall('agent/request-error', owner, 1, 1, error, failure, priorFailures, signal, next)
|
||||
return agentEvents(ctx, owner).waterfall(
|
||||
'agent/request-error', 1, 1, error, failure, priorFailures, signal, next,
|
||||
)
|
||||
}
|
||||
|
||||
function overflow(message = 'provider overflow'): Error & { code: string } {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
|
||||
@@ -112,10 +115,17 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
@@ -262,7 +272,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter(delivery)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -324,7 +334,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter('thrown', true)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(LlmRetry, {
|
||||
maxTransientRetries: 1,
|
||||
initialDelayMs: 1,
|
||||
|
||||
@@ -6,14 +6,35 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../llm/token-meter" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../compact" },
|
||||
{ "path": "../compact-tool-result-prune" }
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../compact"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../compact-tool-result-prune"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
27
packages/compact/compact-tool-result-prune/src/invariant.ts
Normal file
27
packages/compact/compact-tool-result-prune/src/invariant.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-compact-tool-result-prune`.
|
||||
* @module @deepseek-ai/dsh-compact-tool-result-prune/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'compact-tool-result-prune-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: Session validates each content-only rewrite and its companion owns cross-event enclosure. */
|
||||
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 */
|
||||
@@ -4,7 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import ToolResultPruneService, {
|
||||
codePointLength,
|
||||
DEFAULTS,
|
||||
@@ -225,7 +226,8 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
it('runs under real invariants between closed steps but not outside a turn', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
const prune = new ToolResultPruneService(ctx, SMALL)
|
||||
const session = ctx.sessions.create(SessionId('invariants'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" }
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,22 +11,29 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
111
packages/compact/compact/src/invariant.ts
Normal file
111
packages/compact/compact/src/invariant.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type {} from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-compact'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'compact-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
interface CompactionTrace {
|
||||
turn: number
|
||||
summarized: boolean
|
||||
}
|
||||
|
||||
type CompactionTransition =
|
||||
| { kind: 'start'; turn: number }
|
||||
| { kind: 'summary'; turn: number }
|
||||
| { kind: 'end' }
|
||||
|
||||
/** Validate one compaction event without advancing committed trace state. */
|
||||
function validateCompactionEvent(
|
||||
open: CompactionTrace | undefined,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): CompactionTransition | undefined {
|
||||
if (event.type === 'compact/start') {
|
||||
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
|
||||
return { kind: 'start', turn: event.data.turn }
|
||||
}
|
||||
if (event.type === 'compact/summary') {
|
||||
if (open === undefined) fail('compact/summary has no matching compact/start')
|
||||
if (open.summarized) fail('compact/summary repeated within one compaction')
|
||||
const seqs = event.data.shadowedSeqs
|
||||
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
|
||||
if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) {
|
||||
fail('compact/summary shadowedRange must match the first and last shadowedSeqs')
|
||||
}
|
||||
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
|
||||
fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
|
||||
}
|
||||
return { kind: 'summary', turn: open.turn }
|
||||
}
|
||||
if (event.type !== 'compact/end') return undefined
|
||||
if (open === undefined) fail('compact/end has no matching compact/start')
|
||||
if (event.data.turn !== open.turn) {
|
||||
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
|
||||
}
|
||||
if (event.data.error === undefined && !open.summarized) {
|
||||
fail('successful compact/end requires one compact/summary')
|
||||
}
|
||||
return { kind: 'end' }
|
||||
}
|
||||
|
||||
/** Apply one committed compaction transition. */
|
||||
function applyCompactionTransition(
|
||||
transition: CompactionTransition,
|
||||
): CompactionTrace | undefined {
|
||||
if (transition.kind === 'start') return { turn: transition.turn, summarized: false }
|
||||
if (transition.kind === 'summary') return { turn: transition.turn, summarized: true }
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Install compaction start/summary/end checks. */
|
||||
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
|
||||
/* jscpd:ignore-start */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, CompactionTrace>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; transition: CompactionTransition }>()
|
||||
const seed = (session: Session): void => {
|
||||
let open: CompactionTrace | undefined
|
||||
for (const event of session.events) {
|
||||
const transition = validateCompactionEvent(open, event, fail)
|
||||
if (transition !== undefined) open = applyCompactionTransition(transition)
|
||||
}
|
||||
if (open !== undefined) traces.set(session, open)
|
||||
}
|
||||
const traceFor = (session: Session): CompactionTrace | undefined => traces.get(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every compaction event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
|
||||
staged.delete(event)
|
||||
const next = applyCompactionTransition(candidate.transition)
|
||||
if (next === undefined) traces.delete(session)
|
||||
else traces.set(session, next)
|
||||
}, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateCompactionEvent(traceFor(session), event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the compact 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))
|
||||
@@ -38,7 +38,7 @@ class StubCompactService extends CompactService {
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedSeqs: [start],
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
@@ -50,7 +50,7 @@ class StubCompactService extends CompactService {
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedSeqs: [start],
|
||||
shadowedTokenCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
90
packages/compact/compact/tests/invariant.spec.ts
Normal file
90
packages/compact/compact/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const summary = (overrides: Record<string, unknown> = {}) => ({
|
||||
summary: [{ type: 'text' as const, text: 'short' }],
|
||||
shadowedRange: { start: 2, end: 4 },
|
||||
shadowedSeqs: [2, 3, 4],
|
||||
shadowedTokenCount: 12,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('compaction invariants', () => {
|
||||
it('accepts successful and failed compaction lifecycles', async () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
success.append('compact/start', { turn: 1 })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: 1 })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
failed.append('compact/start', { turn: 2 })
|
||||
failed.append('compact/end', { turn: 2, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('rebuilds an open trace when the companion loads after the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('compact/start', { turn: 3 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['summary without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/summary', summary())
|
||||
}, /no matching compact\/start/],
|
||||
['nested start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { turn: 2 })
|
||||
}, /still compacting/],
|
||||
['repeated summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary())
|
||||
session.append('compact/summary', summary())
|
||||
}, /repeated within one compaction/],
|
||||
['empty shadow set', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedSeqs: [] }))
|
||||
}, /shadowedSeqs must be non-empty/],
|
||||
['wrong endpoints', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } }))
|
||||
}, /shadowedRange must match/],
|
||||
['invalid token count', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedTokenCount: -1 }))
|
||||
}, /non-negative safe integer/],
|
||||
['end without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/end', { turn: 1, error: 'failed' })
|
||||
}, /no matching compact\/start/],
|
||||
['wrong end turn', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 2, error: 'failed' })
|
||||
}, /does not match/],
|
||||
['success without summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 1 })
|
||||
}, /requires one compact\/summary/],
|
||||
])('rejects %s', async (_name, action, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { action(ctx.sessions.create()) }).toThrow(message)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ Step 1 measures from the latest preceding model-visible message, including the p
|
||||
|
||||
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||
|
||||
The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -26,12 +31,15 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
114
packages/context/time-context/src/invariant.ts
Normal file
114
packages/context/time-context/src/invariant.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
const SOURCE_NAME = 'time-context'
|
||||
const READING = new RegExp(
|
||||
'^Time sampled while preparing turn (\\d+), step (\\d+): '
|
||||
+ '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n'
|
||||
+ 'Elapsed since the preceding (model-visible message|step context): '
|
||||
+ '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$',
|
||||
)
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'time-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Derive the pre-step position at which a time-context reading may append. */
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
const currentTurnEvents: SessionEvent[] = []
|
||||
let openTurn: number | undefined
|
||||
for (const event of history.slice().reverse()) {
|
||||
if (event.type === 'turn/end') {
|
||||
fail('time-context reading must be appended inside an open turn')
|
||||
}
|
||||
if (event.type === 'turn/start') {
|
||||
openTurn = event.data.turn
|
||||
break
|
||||
}
|
||||
currentTurnEvents.push(event)
|
||||
}
|
||||
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
|
||||
|
||||
for (const event of currentTurnEvents) {
|
||||
if (event.type === 'step/start') {
|
||||
fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`)
|
||||
}
|
||||
if (event.type === 'step/end') {
|
||||
return { turn: openTurn, step: event.data.step + 1 }
|
||||
}
|
||||
}
|
||||
return { turn: openTurn, step: 1 }
|
||||
}
|
||||
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'context/message'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const [block] = event.data.content
|
||||
if (event.data.content.length !== 1 || block?.type !== 'text') {
|
||||
fail('time-context messages must contain exactly one text block')
|
||||
}
|
||||
const match = READING.exec(block.text)
|
||||
if (match === null) fail('time-context message does not match the durable reading format')
|
||||
const turn = Number(match[1])
|
||||
const step = Number(match[2])
|
||||
if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) {
|
||||
fail('time-context turn and step must be positive safe integers')
|
||||
}
|
||||
const expected = preparationPosition(history, fail)
|
||||
if (turn !== expected.turn || step !== expected.step) {
|
||||
fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`)
|
||||
}
|
||||
const baseline = match[4]
|
||||
if ((step === 1) !== (baseline === 'model-visible message')) {
|
||||
fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
const rendered = match[3]
|
||||
/* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */
|
||||
if (rendered === undefined) fail('time-context reading omitted its rendered timestamp')
|
||||
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ''))
|
||||
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time)
|
||||
|| event.time < renderedTime) {
|
||||
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate all package-owned readings already present in one session. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const [index, event] of session.events.entries()) {
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended context readings. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the time-context 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))
|
||||
177
packages/context/time-context/tests/invariant.spec.ts
Normal file
177
packages/context/time-context/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const SECOND = Date.parse('2026-07-14T00:00:00Z')
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(TimeInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
|
||||
return {
|
||||
type: 'context/message',
|
||||
seq: 0,
|
||||
time,
|
||||
data: {
|
||||
content: (content ?? [{ type: 'text', text }]) as ContentBlock[],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function reading(
|
||||
turn = '1',
|
||||
step = '1',
|
||||
baseline = 'model-visible message',
|
||||
timestamp = '2026-07-14T00:00:00+00:00[UTC]',
|
||||
): string {
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: unavailable.`
|
||||
}
|
||||
|
||||
function preparing(turn: number, step: number): Session {
|
||||
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
for (let priorStep = 1; priorStep < step; priorStep += 1) {
|
||||
session.append('step/start', { turn, step: priorStep })
|
||||
session.append('step/end', { turn, step: priorStep })
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function appendReading(session: Session, text: string): void {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('time-context invariants', () => {
|
||||
it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
|
||||
const ctx = await setup()
|
||||
const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Elapsed since the preceding step context: 4m 2s.'
|
||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a reading durably appended after a long process pause', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('validates each existing reading against its preceding durable prefix', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading())
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an invalid existing reading on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading('1', '2', 'step context'))
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[reading('1', '3', 'step context'), /expected turn 2\/step 3/],
|
||||
[reading('2', '2', 'step context'), /expected turn 2\/step 3/],
|
||||
])('rejects a reading that disagrees with its session position', async (text, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a reading after cancellation closes the turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = preparing(1, 2)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
|
||||
.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('rejects a reading after step/start or without any open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const started = preparing(1, 1)
|
||||
started.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
|
||||
}).toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not a reading', SECOND, undefined, /durable reading format/],
|
||||
[reading('0'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('999999999999999999999'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||
[reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||
[reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
|
||||
[reading(), Number.NaN, undefined, /must parse and not postdate/],
|
||||
[reading(), SECOND - 1, undefined, /must parse and not postdate/],
|
||||
['ignored', SECOND, [], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
|
||||
] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
|
||||
const ctx = await setup()
|
||||
const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, preparationStep), event(
|
||||
text,
|
||||
time,
|
||||
content === undefined ? undefined : [...content],
|
||||
))
|
||||
}).toThrow(message)
|
||||
})
|
||||
|
||||
it('ignores context messages owned by another package', async () => {
|
||||
const ctx = await setup()
|
||||
const other = event('unrelated') as SessionEvent<'context/message'>
|
||||
other.data.source = { kind: 'plugin', plugin: 'other' }
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
|
||||
other.data.source = { kind: 'user' }
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
ctx.emit('tools/change')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
@@ -83,7 +82,7 @@ async function fire(
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, signal)
|
||||
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
|
||||
@@ -6,13 +6,35 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../support/loader-smoke" }
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -39,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
30
packages/context/workspace-context/src/invariant.ts
Normal file
30
packages/context/workspace-context/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace-context`.
|
||||
* @module @deepseek-ai/dsh-workspace-context/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workspace-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata,
|
||||
* while focused pipeline tests own its private pending/cache state transitions.
|
||||
*/
|
||||
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 */
|
||||
@@ -7,8 +7,9 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
@@ -23,7 +24,12 @@ import type {
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
PostToolDecision,
|
||||
ToolExecution,
|
||||
ToolExecutionResult,
|
||||
ToolExecutionToken,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import {
|
||||
discoverBaselineInstructionFiles,
|
||||
@@ -225,14 +231,31 @@ const composedPrefixes = new WeakMap<object, Message[]>()
|
||||
|
||||
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
const prefix = await ctx.waterfall(
|
||||
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
|
||||
const prefix = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, AbortSignal.timeout(1000),
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
composedPrefixes.set(agent, prefix)
|
||||
return prefix
|
||||
}
|
||||
|
||||
function toolEventCarrier(ctx: Context, exec: ToolExecution) {
|
||||
return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent)
|
||||
}
|
||||
|
||||
function postExecute(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
result: Readonly<ToolExecutionResult>,
|
||||
next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next)
|
||||
}
|
||||
|
||||
function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly<ToolExecutionResult>): void {
|
||||
ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result)
|
||||
}
|
||||
|
||||
function derivedText(agent: Agent): string {
|
||||
return blocksText(composedPrefixes.get(agent)?.[0]?.content)
|
||||
}
|
||||
@@ -795,7 +818,7 @@ describe('workspace context request injection', () => {
|
||||
try {
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -842,7 +865,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
|
||||
// A later PostToolUse-style policy blocks this otherwise-successful read.
|
||||
const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
const blocked = await postExecute(ctx, exec, result, async () => ({
|
||||
kind: 'block' as const,
|
||||
feedback: [{ type: 'text' as const, text: 'blocked by policy' }],
|
||||
}))
|
||||
@@ -856,7 +879,7 @@ describe('workspace context request injection', () => {
|
||||
// The same read, when the downstream accepts, DOES surface the nested
|
||||
// instructions — proving the block branch above is what suppressed them,
|
||||
// and that the block did not consume the pending nested change.
|
||||
const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
const accepted = await postExecute(ctx, exec, result, async () => ({
|
||||
kind: 'accept' as const,
|
||||
}))
|
||||
expect(accepted.kind).toBe('accept')
|
||||
@@ -1168,8 +1191,9 @@ describe('workspace context request injection', () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel prefix')
|
||||
const empty: Message[] = []
|
||||
const pending = ctx.waterfall(
|
||||
'agent/session-prefix', stubAgent(root), empty, controller.signal,
|
||||
const agent = stubAgent(root)
|
||||
const pending = agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, controller.signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
|
||||
@@ -1659,7 +1683,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const pending = ctx.waterfall('tools/post-execute', exec, {
|
||||
const pending = postExecute(ctx, exec, {
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}, () => Promise.resolve({ kind: 'accept' as const }))
|
||||
@@ -2385,12 +2409,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
isError: false,
|
||||
}
|
||||
|
||||
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const failedStat = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
fs.throwOnStat.clear()
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
|
||||
const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const mismatchedStat = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
|
||||
@@ -2624,19 +2648,19 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const parent = Symbol('parent') as ToolExecutionToken
|
||||
const plainResult = { callId: CallId('plain'), content: [], isError: false }
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
|
||||
}), plainResult)
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
|
||||
ctx.emit('tools/result', {
|
||||
emitToolResult(ctx, {
|
||||
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
token: parent,
|
||||
}, plainResult)
|
||||
@@ -2672,7 +2696,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
|
||||
name: item.name,
|
||||
arguments: item.arguments,
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -30,16 +36,17 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "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-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@cordisjs/plugin-timer": "workspace:^"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'invariants',
|
||||
summary: 'Package-owned invariant registry with global and regex-based selection.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(packageName: string, installer: InvariantInstaller): () => void',
|
||||
jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - listener or startup-check installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'llm',
|
||||
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
|
||||
@@ -1340,6 +1350,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'InjectOptions',
|
||||
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvariantFailure',
|
||||
declaration: 'export type InvariantFailure = (message: string) => never;',
|
||||
},
|
||||
{
|
||||
name: 'InvariantInstaller',
|
||||
declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise<void>;\n readonly inject?: Inject;\n}',
|
||||
},
|
||||
{
|
||||
name: 'JsonValue',
|
||||
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
|
||||
|
||||
30
packages/cordis/tool-cordis/src/invariant.ts
Normal file
30
packages/cordis/tool-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
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 */
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
||||
|
||||
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
|
||||
|
||||
### Invariant companion
|
||||
|
||||
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
|
||||
|
||||
### Configuration (schemastery)
|
||||
|
||||
```ts
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -22,6 +27,7 @@
|
||||
"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-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
|
||||
76
packages/core/agent-loop/src/invariant.ts
Normal file
76
packages/core/agent-loop/src/invariant.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Package-owned request-reconstruction invariant for loop-built LLM calls.
|
||||
* @module @deepseek-ai/dsh-agent-loop/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { isLoopRequest } from './request-marker.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'agent-loop-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install the request-reconstruction contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
// Prepend prevents a short-circuiting replay listener from silencing the
|
||||
// check; correctness itself comes from the sequence-bounded reconstruction.
|
||||
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
||||
if (!isLoopRequest(options)) return next()
|
||||
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
|
||||
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
|
||||
const session = ctx.sessions.get(options.sessionId)
|
||||
if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`)
|
||||
if (!Object.isFrozen(options.messages)) {
|
||||
fail('a loop-built request must carry a frozen messages array')
|
||||
}
|
||||
|
||||
const events = session.events
|
||||
let boundary = -1
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
if (events[index]?.type === 'step/start') {
|
||||
boundary = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (boundary === -1) {
|
||||
return fail('a loop-built request with no step/start in its session log')
|
||||
}
|
||||
const header = foldRequestHeader(events)
|
||||
if (header === undefined) {
|
||||
return fail('a loop-built request with no request/header event in its session log')
|
||||
}
|
||||
const rebuilt = new Session(
|
||||
SessionId(`${String(session.id)}-invariant-rebuild`),
|
||||
structuredClone(events.slice(0, boundary)),
|
||||
)
|
||||
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
|
||||
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
|
||||
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
|
||||
}
|
||||
|
||||
const headerMatches = options.model === header.config.model
|
||||
&& options.system === header.system
|
||||
&& options.temperature === header.config.temperature
|
||||
&& options.maxTokens === header.config.maxTokens
|
||||
&& JSON.stringify(options.stop) === JSON.stringify(header.config.stop)
|
||||
&& JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? [])
|
||||
if (!headerMatches) {
|
||||
fail(`llm request for session "${String(session.id)}" diverges from the folded request header`)
|
||||
}
|
||||
return next()
|
||||
}, { global: true, prepend: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
/**
|
||||
* Register the agent-loop 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))
|
||||
@@ -15,6 +15,7 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
import type { TransmissionLog } from './request-log.ts'
|
||||
import { markLoopRequest } from './request-marker.ts'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -619,7 +620,7 @@ async function runStep(
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
const request: GenerateOptions = deepFreeze(markLoopRequest({
|
||||
provider: header.config.provider,
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
@@ -630,7 +631,7 @@ async function runStep(
|
||||
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
|
||||
sessionId: session.id,
|
||||
signal,
|
||||
})
|
||||
}))
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
|
||||
22
packages/core/agent-loop/src/request-marker.ts
Normal file
22
packages/core/agent-loop/src/request-marker.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Internal identity shared by the independently bundled loop and invariant companion. */
|
||||
|
||||
const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request')
|
||||
|
||||
/**
|
||||
* Mark a request as owned by the agent loop before it is frozen.
|
||||
* @param request - mutable request object being assembled by the loop.
|
||||
* @returns the same request with a non-enumerable loop identity.
|
||||
*/
|
||||
export function markLoopRequest<T extends object>(request: T): T {
|
||||
Object.defineProperty(request, LOOP_REQUEST, { value: true })
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a request carries the agent loop's internal identity.
|
||||
* @param request - request observed at the LLM stream boundary.
|
||||
* @returns whether the loop marked this exact request object.
|
||||
*/
|
||||
export function isLoopRequest(request: object): boolean {
|
||||
return Reflect.get(request, LOOP_REQUEST) === true
|
||||
}
|
||||
@@ -7,9 +7,19 @@ import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/ds
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
@@ -144,7 +154,7 @@ describe('successful provider completion survives agent/step-result failure', ()
|
||||
): Promise<void> {
|
||||
const adapter = new MockAdapter([response])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' })
|
||||
const failure = new Error(`${id} result processing failed`)
|
||||
const reported: Error[] = []
|
||||
@@ -1041,7 +1051,7 @@ describe('step boundary publication order', () => {
|
||||
})
|
||||
|
||||
describe('turn and step boundary recovery', () => {
|
||||
// The invariants plugin makes an unbalanced log fail the test.
|
||||
// The session invariant companion makes an unbalanced log fail the test.
|
||||
async function balancedHarness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -1050,7 +1060,7 @@ describe('turn and step boundary recovery', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -1468,7 +1478,7 @@ describe('surface: assistant/message records exact empty provenance when no chun
|
||||
// stream from legacy events whose provenance was not recorded.
|
||||
const adapter = new MockAdapter([[]])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
|
||||
@@ -1505,7 +1515,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Parent-owned listener survives agent-fiber disposal.
|
||||
@@ -1556,7 +1566,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
@@ -1611,7 +1621,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
@@ -1662,7 +1672,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
@@ -1711,7 +1721,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
|
||||
130
packages/core/agent-loop/tests/invariant.spec.ts
Normal file
130
packages/core/agent-loop/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { markLoopRequest } from '../src/request-marker.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function dispatch(ctx: Context, options: unknown): void {
|
||||
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
|
||||
}
|
||||
|
||||
function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
return Object.freeze(markLoopRequest(options))
|
||||
}
|
||||
|
||||
async function requestSetup() {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const boundary = session.deriveMessages()
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||
return { ctx, session, boundary }
|
||||
}
|
||||
|
||||
describe('request-reconstruction invariant', () => {
|
||||
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('requires the folded session prefix ahead of derived history', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
|
||||
.not.toThrow()
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
|
||||
it('rejects message and header divergence', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the folded request header/)
|
||||
})
|
||||
|
||||
it('rejects loop requests with no boundary or header', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
|
||||
})
|
||||
|
||||
it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) })
|
||||
.toThrow(/frozen messages array/)
|
||||
expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
|
||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
|
||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
|
||||
.not.toThrow()
|
||||
|
||||
const directSession = ctx.sessions.create(SessionId('direct-one-shot'))
|
||||
expect(() => {
|
||||
dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects malformed requests carrying the loop marker', async () => {
|
||||
const { ctx, session } = await requestSetup()
|
||||
expect(() => {
|
||||
dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }))
|
||||
}).toThrow(/request must be frozen/)
|
||||
expect(() => {
|
||||
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
|
||||
}).toThrow(/carry a session id/)
|
||||
expect(() => {
|
||||
dispatch(ctx, loopRequest({
|
||||
model: 'm',
|
||||
messages: Object.freeze([]),
|
||||
sessionId: SessionId('missing-loop-session'),
|
||||
}))
|
||||
}).toThrow(/live session id/)
|
||||
})
|
||||
|
||||
it('prepends ahead of a short-circuiting stream listener', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.on('llm/stream', () => (async function* () {})() as never)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
const session = ctx.sessions.create(SessionId('prepend-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||
const divergent = loopRequest({
|
||||
model: 'm',
|
||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
})
|
||||
@@ -425,7 +425,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
seed,
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
|
||||
})
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.sessions.flush(forked)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume it; the parentSession + seedLength header survives the
|
||||
@@ -485,7 +485,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
await ctx1.parallel('session/flush', a1.session)
|
||||
await ctx1.sessions.flush(a1.session)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume; the injected context is still in the derived history.
|
||||
|
||||
@@ -7,9 +7,19 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -17,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
packages/core/agent-loop/tsdown.config.ts
Normal file
25
packages/core/agent-loop/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
|
||||
|
||||
The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly.
|
||||
|
||||
## Service: `AgentRegistry` (ctx key: `agents`)
|
||||
|
||||
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -31,6 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
35
packages/core/agent/src/invariant.ts
Normal file
35
packages/core/agent/src/invariant.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'agent-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install the agent contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const lastStatus = new WeakMap<Agent, AgentStatus>()
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
const previous = lastStatus.get(agent)
|
||||
if (previous === status) {
|
||||
fail(`agent/status repeated ${status} (no-op transition)`)
|
||||
}
|
||||
if (previous === 'disposed') {
|
||||
fail(`agent/status left terminal state disposed → ${status}`)
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the agent 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))
|
||||
58
packages/core/agent/tests/invariant.spec.ts
Normal file
58
packages/core/agent/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function mockAgent(id: string): Agent {
|
||||
return { id } as unknown as Agent
|
||||
}
|
||||
|
||||
describe('agent status invariants', () => {
|
||||
it('accepts lifecycle transitions through idle, running, and disposed', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a1')
|
||||
expect(() => {
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
}).not.toThrow()
|
||||
|
||||
const running = mockAgent('a2')
|
||||
ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a no-op transition', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a3')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') })
|
||||
.toThrow(/no-op transition/)
|
||||
})
|
||||
|
||||
it('rejects leaving the terminal disposed state', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a4')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
|
||||
.toThrow(/left terminal state disposed/)
|
||||
})
|
||||
|
||||
it('tracks agents independently', async () => {
|
||||
const ctx = await setup()
|
||||
const a = mockAgent('a5')
|
||||
const b = mockAgent('b5')
|
||||
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
packages/core/agent/tsdown.config.ts
Normal file
25
packages/core/agent/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -13,6 +13,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
|
||||
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
|
||||
|
||||
## Design contract
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
|
||||
@@ -11,20 +11,27 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
41
packages/core/scope/src/invariant.ts
Normal file
41
packages/core/scope/src/invariant.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
|
||||
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-scope'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'scope-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install the scoped-dispatch contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args, thisArg) => {
|
||||
const subjectOf = scopedSubjectResolverFor(eventName)
|
||||
if (subjectOf === undefined) return
|
||||
if (!isScopeCarrier(thisArg)) {
|
||||
fail(
|
||||
`"${eventName}" is a scope-filtered event but was dispatched without a scope carrier — `
|
||||
+ 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))',
|
||||
)
|
||||
}
|
||||
if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
|
||||
fail(
|
||||
`"${eventName}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
|
||||
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))',
|
||||
)
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the scope 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))
|
||||
52
packages/core/scope/src/scoped-events.generated.ts
Normal file
52
packages/core/scope/src/scoped-events.generated.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Generated scoped-event routing-subject resolvers for dsh-scope invariants.
|
||||
* Do not edit by hand; run `pnpm run gen-scoped-events`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope/scoped-events.generated
|
||||
*/
|
||||
|
||||
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
|
||||
|
||||
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
|
||||
'agent/cancel-requested': args => args[0],
|
||||
'agent/created': args => args[0],
|
||||
'agent/disposed': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'agent/post-step': args => args[0],
|
||||
'agent/pre-step': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/queued': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
'agent/session-prefix': args => args[0],
|
||||
'agent/session-start': args => args[0],
|
||||
'agent/status': args => args[0],
|
||||
'agent/step-result': args => args[0],
|
||||
'agent/turn-continuation': args => args[0],
|
||||
'agent/turn-stop': args => args[0],
|
||||
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'goal/changed': args => args[0],
|
||||
'session/created': null,
|
||||
'session/disposed': null,
|
||||
'session/event': null,
|
||||
'session/flush': null,
|
||||
'subagent/end': null,
|
||||
'subagent/start': null,
|
||||
'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
|
||||
'tools/execute': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'tools/result': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the routing key named by one scoped event payload. A null
|
||||
* resolver means the payload cannot expose its external routing key, so the
|
||||
* invariant checks carrier presence only.
|
||||
* @param event - runtime Cordis event name.
|
||||
* @returns the generated subject resolver, null for presence-only,
|
||||
* or undefined when the event is not scope-filtered.
|
||||
*/
|
||||
export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {
|
||||
return scopedSubjectResolvers[event]
|
||||
}
|
||||
82
packages/core/scope/tests/invariant.spec.ts
Normal file
82
packages/core/scope/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ScopeInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function emit(ctx: Context, receiver: object | undefined, event: string, args: unknown[]): void {
|
||||
const dispatch = ctx.emit.bind(ctx) as (...values: unknown[]) => void
|
||||
if (receiver === undefined) dispatch(event, ...args)
|
||||
else dispatch(receiver, event, ...args)
|
||||
}
|
||||
|
||||
describe('scoped-dispatch invariants', () => {
|
||||
it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow()
|
||||
const agent = { id: 'a1' }
|
||||
expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) })
|
||||
.toThrow(/dispatched without a scope carrier/)
|
||||
})
|
||||
|
||||
it('checks every generated subject resolver against the carrier key', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = { id: 'a1' }
|
||||
const other = { id: 'a2' }
|
||||
const rows: Array<[string, unknown[]]> = [
|
||||
['agent/created', [agent]],
|
||||
['agent/disposed', [agent]],
|
||||
['agent/error', [agent, 1, 0, new Error('x')]],
|
||||
['agent/post-step', [agent, 1, 1]],
|
||||
['agent/pre-step', [agent, 1, 1, new AbortController().signal]],
|
||||
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]],
|
||||
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
|
||||
['agent/request-error', [agent, 1, 1, new Error('x')]],
|
||||
['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
|
||||
['agent/session-start', [agent, 'startup']],
|
||||
['agent/status', [agent, 'idle']],
|
||||
['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
|
||||
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
|
||||
['agent/turn-stop', [agent, 1]],
|
||||
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
|
||||
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
|
||||
['system-prompt/assemble', [[], { scope: agent }]],
|
||||
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
|
||||
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
|
||||
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
|
||||
]
|
||||
|
||||
for (const [event, args] of rows) {
|
||||
expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} matching`).not.toThrow()
|
||||
expect(() => { emit(ctx, scopeTarget(agent, other), event, args) }, `${event} mismatched`)
|
||||
.toThrow(/DIFFERENT subject/)
|
||||
}
|
||||
})
|
||||
|
||||
it('requires carriers for generated presence-only scoped events without comparing a payload subject', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = { id: 'a1' }
|
||||
const rows: Array<[string, unknown[]]> = [
|
||||
['session/created', [{}]],
|
||||
['session/disposed', [{}]],
|
||||
['session/event', [{}, {}]],
|
||||
['session/flush', [{}]],
|
||||
['subagent/end', [{}]],
|
||||
['subagent/start', [{}]],
|
||||
]
|
||||
for (const [event, args] of rows) {
|
||||
expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} carrier`).not.toThrow()
|
||||
expect(() => { emit(ctx, undefined, event, args) }, `${event} no carrier`)
|
||||
.toThrow(/dispatched without a scope carrier/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
27
packages/core/scope/tsdown.config.ts
Normal file
27
packages/core/scope/tsdown.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
// Preserve the root entry's carrier WeakMap identity across bundles.
|
||||
deps: { neverBundle: ['@deepseek-ai/dsh-scope'] },
|
||||
},
|
||||
])
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
|
||||
|
||||
The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package.
|
||||
|
||||
## Service: `SessionStore` (ctx key: `sessions`)
|
||||
|
||||
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
|
||||
@@ -34,7 +36,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,12 +28,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
238
packages/core/session/src/invariant.ts
Normal file
238
packages/core/session/src/invariant.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Package-owned relational invariants for the session event log. Load this
|
||||
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Per-session bookkeeping for relational log checks. */
|
||||
interface SessionTrace {
|
||||
lastSeq: number
|
||||
openTurn: number | null
|
||||
openStep: number | null
|
||||
nextTurn: number
|
||||
nextStep: number
|
||||
pendingCalls: Set<CallId>
|
||||
}
|
||||
|
||||
/** One accepted event's deferred mutation of a committed session trace. */
|
||||
interface SessionTraceTransition {
|
||||
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
|
||||
pendingCalls:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'add' | 'delete'; callId: CallId }
|
||||
| { kind: 'clear' }
|
||||
}
|
||||
|
||||
/** Assert that a step-scoped event names the currently open turn and step. */
|
||||
function requireOpenStep(
|
||||
trace: SessionTrace,
|
||||
kind: string,
|
||||
turn: number,
|
||||
step: number,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (trace.openTurn !== turn || trace.openStep !== step) {
|
||||
fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one candidate event without mutating the committed trace. */
|
||||
function validateEvent(
|
||||
trace: SessionTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): SessionTraceTransition {
|
||||
if (event.seq <= trace.lastSeq) {
|
||||
fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
|
||||
}
|
||||
let openTurn = trace.openTurn
|
||||
let openStep = trace.openStep
|
||||
let nextTurn = trace.nextTurn
|
||||
let nextStep = trace.nextStep
|
||||
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
||||
|
||||
// SessionEventMap is merge-extensible, so the default enforces turn
|
||||
// enclosure for package-added events as well as the built-in variants.
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (trace.openTurn !== null) {
|
||||
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
|
||||
}
|
||||
if (event.data.turn !== trace.nextTurn) {
|
||||
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
|
||||
}
|
||||
openTurn = event.data.turn
|
||||
nextStep = 1
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
openTurn = null
|
||||
nextTurn += 1
|
||||
break
|
||||
}
|
||||
case 'step/start': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
if (event.data.step !== trace.nextStep) {
|
||||
fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
|
||||
}
|
||||
openStep = event.data.step
|
||||
break
|
||||
}
|
||||
case 'step/end': {
|
||||
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
|
||||
pendingCalls = { kind: 'clear' }
|
||||
openStep = null
|
||||
nextStep += 1
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
|
||||
break
|
||||
}
|
||||
case 'tool/call': {
|
||||
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
|
||||
pendingCalls = { kind: 'add', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
// Session has already validated a provenance-backed content rewrite.
|
||||
// It is durable turn work, not a second execution of the original call.
|
||||
if (event.surfaceOp !== 'append') {
|
||||
if (trace.openTurn === null) {
|
||||
fail('tool/result surface replacement appended outside any open turn')
|
||||
}
|
||||
break
|
||||
}
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
pendingCalls = { kind: 'delete', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (trace.openTurn === null) {
|
||||
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
||||
pendingCalls,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one already-validated transition after its event commits. */
|
||||
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
|
||||
Object.assign(trace, transition.scalars)
|
||||
switch (transition.pendingCalls.kind) {
|
||||
case 'none':
|
||||
break
|
||||
case 'add':
|
||||
trace.pendingCalls.add(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'delete':
|
||||
trace.pendingCalls.delete(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'clear':
|
||||
trace.pendingCalls.clear()
|
||||
break
|
||||
/* v8 ignore next -- validateEvent produces this closed transition union */
|
||||
default:
|
||||
assertNever(transition.pendingCalls, 'session trace pending-call transition')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the session contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, SessionTrace>()
|
||||
const stagedTransitions = new WeakMap<SessionEvent, {
|
||||
session: Session
|
||||
trace: SessionTrace
|
||||
transition: SessionTraceTransition
|
||||
}>()
|
||||
|
||||
const freshTrace = (): SessionTrace => ({
|
||||
lastSeq: -1,
|
||||
openTurn: null,
|
||||
openStep: null,
|
||||
nextTurn: 1,
|
||||
nextStep: 1,
|
||||
pendingCalls: new Set(),
|
||||
})
|
||||
|
||||
const seedSession = (session: Session): SessionTrace => {
|
||||
const trace = freshTrace()
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
applyTransition(trace, validateEvent(trace, event, fail))
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
||||
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seedSession(session)
|
||||
|
||||
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const staged = stagedTransitions.get(event)
|
||||
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
||||
if (staged === undefined || staged.session !== session) {
|
||||
return fail('session/event reached publication without matching pre-commit validation')
|
||||
}
|
||||
stagedTransitions.delete(event)
|
||||
applyTransition(staged.trace, staged.transition)
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const trace = traceFor(session)
|
||||
const transition = validateEvent(trace, event, fail)
|
||||
// A later dispatch listener may veto. Validation is pure, so abandoning
|
||||
// this weakly keyed transition does not advance or retain the session.
|
||||
stagedTransitions.set(event, { session, trace, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
/**
|
||||
* Register the session 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))
|
||||
344
packages/core/session/tests/invariant.spec.ts
Normal file
344
packages/core/session/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
const fiber = await ctx.plugin(SessionInvariant)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('session-log invariants', () => {
|
||||
it('keeps registration global when the companion is mounted under a scope', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
let scopedCtx!: Context
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scopedCtx = createScope(inner, {}).ctx
|
||||
}, { inject: ['sessions', 'invariants'] }))
|
||||
await scopedCtx.plugin(SessionInvariant)
|
||||
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a well-formed turn, step, and tool sequence', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
|
||||
let veto = true
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name !== 'session/event' || !veto) return
|
||||
veto = false
|
||||
throw new Error('later dispatch veto')
|
||||
})
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('later dispatch veto')
|
||||
expect(session.events).toEqual([])
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the committed transition after another postcommit observer throws', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('postcommit-peer'))
|
||||
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
expect(warnings).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects non-monotonic event sequence numbers', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
} as never)
|
||||
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
|
||||
type: 'turn/end',
|
||||
seq: 0,
|
||||
time: 2,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
} as never) }).toThrow(/seq must strictly increase/)
|
||||
})
|
||||
|
||||
it('enforces turn numbering and enclosure', async () => {
|
||||
const first = await setup()
|
||||
const open = first.ctx.sessions.create()
|
||||
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
||||
.toThrow(/does not match open turn 1/)
|
||||
|
||||
const second = (await setup()).ctx.sessions.create()
|
||||
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/expected turn 2, got 3/)
|
||||
|
||||
const outside = (await setup()).ctx.sessions.create()
|
||||
expect(() => outside.append('user/message', {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
|
||||
expect(() => outside.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
|
||||
// Merge-extensible session events use the same default enclosure branch.
|
||||
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
|
||||
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('enforces open-step identity and numbering', async () => {
|
||||
const wrongTurn = (await setup()).ctx.sessions.create()
|
||||
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
|
||||
|
||||
const nested = (await setup()).ctx.sessions.create()
|
||||
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
nested.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
|
||||
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
.toThrow(/while step 1 is still open/)
|
||||
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
|
||||
expect(() => nested.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1,
|
||||
step: 2,
|
||||
content: [],
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
|
||||
|
||||
const skipped = (await setup()).ctx.sessions.create()
|
||||
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
skipped.append('step/start', { turn: 1, step: 1 })
|
||||
skipped.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
|
||||
.toThrow(/expected step 2 in turn 1, got 3/)
|
||||
})
|
||||
|
||||
it('requires step-scoped stream and tool events to name the open step', async () => {
|
||||
const chunk = (await setup()).ctx.sessions.create()
|
||||
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => chunk.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'x' },
|
||||
})).toThrow(/open is turn 1\/step null/)
|
||||
|
||||
const tool = (await setup()).ctx.sessions.create()
|
||||
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
tool.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => tool.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('ghost'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
it('keeps fresh tool-result appends open-step checked', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('closed'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
|
||||
})
|
||||
|
||||
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool-result replacement outside a turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('allows interrupted repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
repaired.append('step/start', { turn: 1, step: 1 })
|
||||
repaired.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('crashed'),
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}, { surfaceOp: 'append' })
|
||||
repaired.append('step/end', { turn: 1, step: 1 })
|
||||
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const unresolved = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
unresolved.append('step/start', { turn: 1, step: 1 })
|
||||
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
unresolved.append('step/end', { turn: 1, step: 1 })
|
||||
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not let a result in a later step satisfy an earlier call', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
|
||||
})
|
||||
|
||||
it('replays seeded sessions and tracks each session independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const badSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
|
||||
|
||||
const a = ctx.sessions.create(SessionId('a'))
|
||||
const b = ctx.sessions.create(SessionId('b'))
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(SessionInvariant)
|
||||
expect(() => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'h' },
|
||||
})).not.toThrow()
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('removes all listeners when the companion is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await fiber.dispose()
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -717,10 +717,11 @@ describe('SessionStore', () => {
|
||||
// may create an unrelated property with the old implementation's name,
|
||||
// but cannot suppress the durable event feed.
|
||||
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]![0]).toBe(session)
|
||||
expect(events[0]![1].type).toBe('user/message')
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[1]![0]).toBe(session)
|
||||
expect(events[1]![1].type).toBe('user/message')
|
||||
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.sessions.list()).toEqual([session])
|
||||
@@ -732,6 +733,7 @@ describe('SessionStore', () => {
|
||||
const a = ctx.sessions.create(SessionId('fixed'))
|
||||
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
|
||||
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
|
||||
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
||||
@@ -973,8 +975,9 @@ describe('SessionStore', () => {
|
||||
ctx.on('session/event', (_session, event) => void events.push(event))
|
||||
const session = ctx.sessions.create(SessionId('fixed'))
|
||||
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events.at(-1)?.type).toBe('user/message')
|
||||
})
|
||||
|
||||
it('contains session/event observer failures after the append commit point', async () => {
|
||||
@@ -1058,6 +1061,8 @@ describe('SessionStore', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'source' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1077,19 +1082,19 @@ describe('SessionStore', () => {
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'replacement' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
surfaceOp: { op: 'replace', start: 2, end: 2 },
|
||||
sourceEventSeqs: [2],
|
||||
})).toThrow('reject surface candidate')
|
||||
|
||||
expect(session.events).toHaveLength(1)
|
||||
expect(surface.nodes).toEqual([0])
|
||||
expect(session.events).toHaveLength(3)
|
||||
expect(surface.nodes).toEqual([2])
|
||||
expect(surface.replaceGeneration).toBe(0)
|
||||
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'next' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(surface.nodes).toEqual([0, 1])
|
||||
expect(surface.nodes).toEqual([2, 3])
|
||||
expect(surface.replaceGeneration).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function toolResultEvent(
|
||||
seq: number,
|
||||
callId: string,
|
||||
surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
|
||||
sourceEventSeqs?: number[],
|
||||
): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq,
|
||||
time: seq,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId(callId),
|
||||
content: [{ type: 'text', text: `result ${seq}` }],
|
||||
isError: false,
|
||||
},
|
||||
surfaceOp,
|
||||
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
|
||||
}
|
||||
}
|
||||
|
||||
describe('foldSurface provenance', () => {
|
||||
it('accepts absent or valid provenance and complete replacement coverage', () => {
|
||||
const events = [
|
||||
@@ -94,6 +116,33 @@ describe('foldSurface provenance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('foldSurface tool-result rewrites', () => {
|
||||
it('rejects a replacement spanning multiple current nodes', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
|
||||
})
|
||||
|
||||
it('rejects a replacement targeting a non-result node', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
|
||||
})
|
||||
|
||||
it('rejects changes outside tool-result content', () => {
|
||||
const events = [
|
||||
toolResultEvent(0, 'original'),
|
||||
toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
packages/core/session/tsdown.config.ts
Normal file
25
packages/core/session/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -11,17 +11,23 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -30,6 +36,7 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
52
packages/core/system-prompt/src/invariant.ts
Normal file
52
packages/core/system-prompt/src/invariant.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { PromptAssembly } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt'
|
||||
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'system-prompt-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the authoritative assembly returned by the waterfall. */
|
||||
function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): void {
|
||||
const sectionNames = new Set<string>()
|
||||
for (const section of assembly.sections) {
|
||||
if (section.name.length === 0) fail('assembled section names must be non-empty')
|
||||
if (sectionNames.has(section.name)) fail(`assembled section name ${JSON.stringify(section.name)} is duplicated`)
|
||||
sectionNames.add(section.name)
|
||||
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
|
||||
}
|
||||
|
||||
for (const tool of assembly.tools) {
|
||||
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(assembly.variables)) {
|
||||
if (!VARIABLE_NAME.test(name)) fail(`assembled variable name ${JSON.stringify(name)} is invalid`)
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
fail(`assembled variable ${JSON.stringify(name)} must be a string or undefined`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation around the authoritative assembly waterfall result. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const assembled = await next()
|
||||
validateAssembly(assembled, fail)
|
||||
return assembled
|
||||
}, { global: true, prepend: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the system-prompt 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))
|
||||
44
packages/core/system-prompt/tests/invariant.spec.ts
Normal file
44
packages/core/system-prompt/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SystemPromptInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const valid = (): PromptAssembly => ({
|
||||
sections: [{ name: 'identity', text: 'prompt' }],
|
||||
tools: [{ name: 'echo', description: 'Echo', parameters: {} }],
|
||||
variables: { cwd: '/repo', optional: undefined },
|
||||
})
|
||||
|
||||
async function assemble(ctx: Context, result: PromptAssembly): Promise<PromptAssembly> {
|
||||
return ctx.waterfall(
|
||||
ctx as never, 'system-prompt/assemble', valid(), {},
|
||||
() => Promise.resolve(result),
|
||||
)
|
||||
}
|
||||
|
||||
describe('system-prompt invariants', () => {
|
||||
it('accepts a well-formed authoritative assembly', async () => {
|
||||
const ctx = await setup()
|
||||
await expect(assemble(ctx, valid())).resolves.toEqual(valid())
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/],
|
||||
[{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/],
|
||||
[{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/],
|
||||
[{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/],
|
||||
[{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/],
|
||||
[{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/],
|
||||
])('rejects malformed authoritative assembly %#', async (assembly, message) => {
|
||||
const ctx = await setup()
|
||||
await expect(assemble(ctx, assembly)).rejects.toThrow(message)
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,12 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -36,12 +42,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
69
packages/core/tools/src/invariant.ts
Normal file
69
packages/core/tools/src/invariant.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ToolExecution, ToolExecutionResult } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tools-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
type ToolStage = 'pre' | 'execute' | 'post'
|
||||
|
||||
/** Validate the immutable final execution/result snapshot. */
|
||||
function validateResult(
|
||||
exec: Readonly<ToolExecution>,
|
||||
result: Readonly<ToolExecutionResult>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication')
|
||||
if (!Object.isFrozen(result) || !Object.isFrozen(result.content)) {
|
||||
fail('tools/result outcome and content must be frozen before publication')
|
||||
}
|
||||
if (exec.name.length === 0 || String(exec.callId).length === 0) {
|
||||
fail('tools/result execution must carry non-empty name and callId')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install monotonic pipeline and final-snapshot checks. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const stages = new WeakMap<object, ToolStage>()
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName === 'tools/pre-execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
if (stages.has(exec)) fail('tools/pre-execute repeated for one execution')
|
||||
stages.set(exec, 'pre')
|
||||
return
|
||||
}
|
||||
if (eventName === 'tools/execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute')
|
||||
stages.set(exec, 'execute')
|
||||
return
|
||||
}
|
||||
if (eventName === 'tools/post-execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
const previous = stages.get(exec)
|
||||
if (previous !== 'pre' && previous !== 'execute') {
|
||||
fail('tools/post-execute must follow tools/pre-execute or tools/execute')
|
||||
}
|
||||
stages.set(exec, 'post')
|
||||
return
|
||||
}
|
||||
if (eventName !== 'tools/result') return
|
||||
const [exec, result] = args as [Readonly<ToolExecution>, Readonly<ToolExecutionResult>]
|
||||
validateResult(exec, result, fail)
|
||||
stages.delete(exec)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the tools 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))
|
||||
84
packages/core/tools/tests/invariant.spec.ts
Normal file
84
packages/core/tools/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ToolsInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
|
||||
token: Symbol('tool') as ToolExecutionToken,
|
||||
callId: CallId('call-1'),
|
||||
name: 'echo',
|
||||
arguments: Object.freeze({ text: 'hi' }),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const outcome = (): ToolExecutionResult => Object.freeze({
|
||||
content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
|
||||
ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
|
||||
}
|
||||
|
||||
async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
|
||||
if (name === 'tools/pre-execute') {
|
||||
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
|
||||
} else {
|
||||
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool-pipeline invariants', () => {
|
||||
it('accepts dispatch and denial stage orders with frozen results', async () => {
|
||||
const ctx = await setup()
|
||||
const dispatched = execution()
|
||||
await stage(ctx, 'tools/pre-execute', dispatched)
|
||||
await stage(ctx, 'tools/execute', dispatched)
|
||||
await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
|
||||
Object.freeze(dispatched)
|
||||
emitResult(ctx, dispatched, outcome())
|
||||
|
||||
const denied = execution({ callId: CallId('call-2') })
|
||||
await stage(ctx, 'tools/pre-execute', denied)
|
||||
await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
|
||||
Object.freeze(denied)
|
||||
emitResult(ctx, denied, outcome())
|
||||
ctx.emit('tools/change')
|
||||
})
|
||||
|
||||
it('rejects repeated and out-of-order pipeline stages', async () => {
|
||||
const ctx = await setup()
|
||||
const exec = execution()
|
||||
await stage(ctx, 'tools/pre-execute', exec)
|
||||
await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
|
||||
|
||||
const noPre = execution({ callId: CallId('call-2') })
|
||||
await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
|
||||
expect(() => ctx.waterfall(
|
||||
ctx as never, 'tools/post-execute', noPre, outcome(),
|
||||
() => Promise.resolve({ kind: 'accept' as const }),
|
||||
)).toThrow(/must follow tools\/pre-execute or tools\/execute/)
|
||||
})
|
||||
|
||||
it('rejects mutable or anonymous final snapshots', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
|
||||
|
||||
const exec = Object.freeze(execution())
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false }) })
|
||||
.toThrow(/outcome and content must be frozen/)
|
||||
|
||||
const anonymous = Object.freeze(execution({ name: '' }))
|
||||
expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./bin": {
|
||||
"types": "./lib/types/bin.d.ts",
|
||||
"default": "./lib/bin.js"
|
||||
@@ -23,6 +27,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
@@ -32,32 +37,34 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-acp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-command-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
|
||||
30
packages/examples/acp-demo/src/invariant.ts
Normal file
30
packages/examples/acp-demo/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-acp-demo`.
|
||||
* @module @deepseek-ai/dsh-acp-demo/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'acp-demo-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this composition package owns no independent event stream or mutable data;
|
||||
* Loader and built-entry tests cover its wiring.
|
||||
*/
|
||||
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 */
|
||||
@@ -21,7 +21,14 @@ import * as acpAgent from '../src/index.ts'
|
||||
*/
|
||||
async function mount(config: acpAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
if (withBash) {
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: undefined,
|
||||
resolve() { throw new Error('composition test does not execute bash') },
|
||||
run() { throw new Error('composition test does not execute bash') },
|
||||
start() { throw new Error('composition test does not execute bash') },
|
||||
})
|
||||
}
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
|
||||
* matching every package.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/bin.js'],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -22,7 +22,12 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
|
||||
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
|
||||
@deepseek-ai/dsh-tasks generic background-task registry
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-invariants configurable invariant registry service
|
||||
@deepseek-ai/dsh-session/invariant
|
||||
@deepseek-ai/dsh-agent/invariant
|
||||
@deepseek-ai/dsh-scope/invariant
|
||||
@deepseek-ai/dsh-agent-loop/invariant
|
||||
package-owned relational checks
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash schema
|
||||
@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader
|
||||
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
|
||||
@@ -46,11 +51,13 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, llmRetry? }
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
|
||||
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
@@ -69,4 +76,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
|
||||
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.
|
||||
- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -27,11 +32,11 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-home": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
@@ -42,6 +47,7 @@
|
||||
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,11 +57,11 @@
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-home": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
@@ -66,6 +72,7 @@
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,7 +22,11 @@ import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-g
|
||||
import * as goalSession from '@deepseek-ai/dsh-goal-session'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
|
||||
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
@@ -63,8 +67,9 @@ export interface GoalConfig {
|
||||
* skill registry/local provider/tool consumer, `workspaceContext` to the
|
||||
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
|
||||
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
|
||||
* `goals` opts into and configures the persisted goal
|
||||
* domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input;
|
||||
* `goals` opts into and configures the persisted goal domain plus its model tool
|
||||
* and same-session driver; `invariants` configures global and package-filtered
|
||||
* relational checks. Owner schemas supply defaults for optional input;
|
||||
* workspace context instead requires an explicit byte budget or `false` because
|
||||
* it changes model-visible input. Producer opt-in stays producer-local:
|
||||
* `toolBash` configures bash only; independently composed producers keep their
|
||||
@@ -91,6 +96,8 @@ export interface Config {
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
|
||||
toolTasks?: toolTasks.Config | false
|
||||
/** Global enablement and package-name filters for invariant companions. */
|
||||
invariants?: InvariantConfig
|
||||
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
|
||||
goals?: GoalConfig | false
|
||||
/** Bounded transient model-request retry policy. */
|
||||
@@ -131,9 +138,10 @@ export const Config = z.intersect([
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
toolBash: ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
|
||||
invariants: InvariantService.Config,
|
||||
goals: z.union([z.const(false), GoalConfigSchema]),
|
||||
llmRetry: LlmRetryConfigSchema,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals' | 'llmRetry'>>,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals' | 'llmRetry'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -152,6 +160,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
...config.invariants !== undefined ? { invariants: config.invariants } : {},
|
||||
...config.goals !== undefined ? { goals: config.goals } : {},
|
||||
...config.llmRetry !== undefined ? { llmRetry: config.llmRetry } : {},
|
||||
}
|
||||
@@ -197,7 +206,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(goalSession)
|
||||
}
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(InvariantService, config.invariants ?? {})
|
||||
ctx.plugin(sessionInvariant)
|
||||
ctx.plugin(agentInvariant)
|
||||
ctx.plugin(scopeInvariant)
|
||||
ctx.plugin(agentLoopInvariant)
|
||||
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
|
||||
if (config.workspaceContext !== false) {
|
||||
ctx.plugin(workspaceContext, config.workspaceContext)
|
||||
|
||||
30
packages/examples/agent-spine-demo/src/invariant.ts
Normal file
30
packages/examples/agent-spine-demo/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-spine-demo`.
|
||||
* @module @deepseek-ai/dsh-agent-spine-demo/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'agent-spine-demo-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this composition package owns no independent event stream or mutable data;
|
||||
* Loader and built-entry tests cover its wiring.
|
||||
*/
|
||||
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 */
|
||||
@@ -12,6 +12,10 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -44,7 +48,14 @@ async function mount(config: agentCore.Config, withBash = false): Promise<Contex
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-'))
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
if (withBash) {
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: undefined,
|
||||
resolve() { throw new Error('composition test does not execute bash') },
|
||||
run() { throw new Error('composition test does not execute bash') },
|
||||
start() { throw new Error('composition test does not execute bash') },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await ctx.plugin(agentCore, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
@@ -123,6 +134,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(ctx.get('skills')).toBeDefined()
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('tasks')).toBeDefined()
|
||||
expect(ctx.get('invariants')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -156,6 +168,28 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('mounts package companions and forwards invariant selection config', async () => {
|
||||
const nestedTurn = (ctx: Context): void => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}
|
||||
|
||||
const enabled = await mount({ workspaceContext: false })
|
||||
expect(() => { nestedTurn(enabled) }).toThrow(/turn 1 is still open/)
|
||||
await enabled.fiber.dispose()
|
||||
|
||||
for (const invariants of [
|
||||
{ enabled: false },
|
||||
{ package_allowlist: ['^@deepseek-ai/dsh-agent$'] },
|
||||
{ package_blocklist: ['^@deepseek-ai/dsh-session$'] },
|
||||
]) {
|
||||
const filtered = await mount({ workspaceContext: false, invariants })
|
||||
expect(() => { nestedTurn(filtered) }).not.toThrow()
|
||||
await filtered.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads and configures bounded request recovery for every bundled front door', async () => {
|
||||
const adapter = new TransientOnceAdapter()
|
||||
const ctx = await mount({
|
||||
@@ -457,6 +491,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: { enabled: false },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: false as const,
|
||||
invariants: { enabled: false },
|
||||
llmRetry: { maxTransientRetries: 1, jitterRatio: 0 },
|
||||
}
|
||||
|
||||
@@ -469,6 +504,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: appConfig.skills,
|
||||
toolBash: appConfig.toolBash,
|
||||
toolTasks: appConfig.toolTasks,
|
||||
invariants: appConfig.invariants,
|
||||
llmRetry: appConfig.llmRetry,
|
||||
})
|
||||
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
|
||||
@@ -530,4 +566,16 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('keeps each standard-spine invariant companion loadable through the real Loader unwrap path', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
|
||||
expect('default' in companion).toBe(false)
|
||||
const unwrapped = loader.unwrapExports(companion) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(companion)
|
||||
expect(typeof unwrapped.name).toBe('string')
|
||||
expect(unwrapped.inject).toContain('invariants')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./bin": {
|
||||
"types": "./lib/types/bin.d.ts",
|
||||
"default": "./lib/bin.js"
|
||||
@@ -23,6 +27,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
@@ -35,6 +40,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-app-boot": "^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-jsonl": "^0.0.1",
|
||||
@@ -49,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user