Merge master into fix/tui-color-scheme-v2
This commit is contained in:
@@ -5,7 +5,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries.
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
@@ -15,7 +14,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:
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
|
||||
@@ -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,11 +29,12 @@
|
||||
"peerDependencies": {
|
||||
"@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-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -42,11 +48,11 @@
|
||||
"@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-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
|
||||
@@ -357,7 +357,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName: 'bash',
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -422,8 +422,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// Reject pre-start cancellation; returned tasks use their own lifecycle.
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
// The caller owns cancellation until TaskService commits detached ownership.
|
||||
if (exec.signal.aborted) return []
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
@@ -442,7 +442,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result, escalationModes) }]
|
||||
|
||||
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 */
|
||||
@@ -7,10 +7,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function execution(sessionId?: string): ToolExecution {
|
||||
return {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -21,6 +21,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderProcessRead, renderResult } from '../src/render.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
|
||||
|
||||
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
|
||||
@@ -67,7 +69,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un
|
||||
}
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
@@ -179,7 +181,11 @@ async function setupSandboxed(withApproval = false) {
|
||||
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
|
||||
}
|
||||
|
||||
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
|
||||
function sandboxAgent(
|
||||
mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
|
||||
ctx?: Context,
|
||||
onAppend?: (type: string) => void,
|
||||
): Agent {
|
||||
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
|
||||
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
|
||||
const id = SessionId('sandbox-session')
|
||||
@@ -193,6 +199,7 @@ function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-acce
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
const event = { type, data }
|
||||
events.push(event)
|
||||
onAppend?.(type)
|
||||
return event
|
||||
},
|
||||
},
|
||||
@@ -451,7 +458,7 @@ describe('background execution through the task runtime', () => {
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
})
|
||||
|
||||
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
|
||||
it('a pre-aborted call is skipped before the process starts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -470,7 +477,8 @@ describe('background execution through the task runtime', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('command aborted')
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
|
||||
expect(text(result)).toBe('Error: tool call aborted before dispatch')
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
@@ -597,6 +605,29 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
|
||||
})
|
||||
|
||||
it('does not publish detached work when cancellation follows the escalation grant', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const controller = new AbortController()
|
||||
const agent = sandboxAgent(undefined, ctx, (type) => {
|
||||
if (type === 'approval/decided') controller.abort()
|
||||
})
|
||||
ctx.agents.register(agent)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const start = vi.spyOn(bash, 'start')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('cancelled-escalation-background'),
|
||||
name: 'bash',
|
||||
arguments: { ...escalate, run_in_background: true },
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
|
||||
expect(text(result)).toBe('Error: tool call aborted')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const agent = sandboxAgent('workspace-write')
|
||||
@@ -730,7 +761,7 @@ describe('session-cwd routing (per-session workdir)', () => {
|
||||
it('falls back to the executor default when the agent has no session cwd', async () => {
|
||||
const ctx = await setup()
|
||||
// No exec.agent at all → executor uses its config/process.cwd() default.
|
||||
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result).trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -1012,6 +1043,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
|
||||
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('session-env-fg'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'run command' },
|
||||
@@ -1032,6 +1064,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
|
||||
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('session-env-bg'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
@@ -1058,6 +1091,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
const ambient = process.env.DSH_SESSION_ID
|
||||
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('session-env-id-only'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'run command' },
|
||||
@@ -1079,6 +1113,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
|
||||
for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`session-env-${callId}`),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'run command' },
|
||||
@@ -1109,6 +1144,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
// This preserves the request shape; it is not a security boundary because shell syntax can
|
||||
// already set environment variables or feed stdin.
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-forward-1'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
@@ -1130,6 +1166,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
it('a background bash call likewise carries no trusted-only fields', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-forward-2'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/home"
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-compact-basic
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`).
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
@@ -9,32 +9,39 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
|
||||
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
|
||||
Every setting is optional. Top-level policy fields are defaults for every routed model; `modelPolicies` applies partial overrides to exact provider/model pairs. At pressure time, compact-basic asks the owning LLM adapter for that route's context capacity and resolves absolute budgets. Unrecognized keys, duplicate targets, mutually exclusive retention forms, and a merged `retainRatio` that is not below `thresholdRatio` fail plugin load. An absolute `retainTokens` budget that is not below its scaled threshold fails on the first resolvable target because that comparison requires model capacity.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||
| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(routedContextWindow × ratio)`. |
|
||||
| `retainRatio` | no (default `0.16`) | Recent surface budget kept verbatim as a fraction of the routed context window; mutually exclusive with `retainTokens`. |
|
||||
| `retainTokens` | no | Absolute recent surface budget kept verbatim; mutually exclusive with `retainRatio` and must be below the resolved threshold. |
|
||||
| `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
|
||||
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
|
||||
| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
|
||||
|
||||
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.
|
||||
|
||||
An adapter may return no capacity for a valid dynamic route, and resolved capacity may expose an invalid absolute retention budget. Manual pressure checks then throw a target-specific configuration error; the automatic listener warns once for that exact target and continues with full history. Unrelated operational failures remain independently visible. Canonical provider overflow still attempts recovery because the provider has already established that compaction is necessary.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
@@ -53,6 +60,20 @@ export function apply(ctx: Context): void {
|
||||
|
||||
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
|
||||
|
||||
For example, the same compact plugin can safely serve models with different capacities and one target-specific policy:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainRatio: 0.16
|
||||
modelPolicies:
|
||||
- provider: local
|
||||
model: small-context
|
||||
thresholdRatio: 0.7
|
||||
retainTokens: 2048
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Conversation history
|
||||
@@ -75,30 +96,16 @@ Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces t
|
||||
|
||||
Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable.
|
||||
|
||||
### Auxiliary summarizer user message
|
||||
### Auxiliary summarizer request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
|
||||
#### Token effect
|
||||
|
||||
This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token.
|
||||
|
||||
### Auxiliary summarizer system prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The summarization model receives the checkpoint-writing instruction below.
|
||||
|
||||
##### Auxiliary summarizer system prompt
|
||||
##### Compaction instruction (final user message)
|
||||
|
||||
```markdown
|
||||
You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.
|
||||
You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.
|
||||
|
||||
Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.
|
||||
|
||||
@@ -129,17 +136,18 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization process or that the context was compacted.
|
||||
- If the transcript already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
- If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
|
||||
This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction.
|
||||
The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached. Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,111 +1,310 @@
|
||||
/**
|
||||
* Runtime defaulting and policy validation for compact-basic.
|
||||
* Load-time validation and routed-model policy resolution for compact-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/config
|
||||
*/
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
CompactPolicyConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedCompactSpec,
|
||||
ResolvedConfig,
|
||||
ResolvedRetention,
|
||||
ResolvedTargetPolicy,
|
||||
} from './types.ts'
|
||||
|
||||
/** Default request-pressure fraction of the token meter's context window. */
|
||||
/** Default request-pressure fraction for every routed model. */
|
||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||
|
||||
/** Default verbatim-tail fraction of the token meter's context window. */
|
||||
/** Default verbatim-tail fraction for every routed model. */
|
||||
const DEFAULT_RETAIN_RATIO = 0.16
|
||||
|
||||
/** Complete public configuration key set. */
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
/** Fields shared by top-level defaults and exact-target overrides. */
|
||||
const POLICY_CONFIG_KEYS = [
|
||||
'thresholdRatio',
|
||||
'retainRatio',
|
||||
'retainTokens',
|
||||
'summarizationProvider',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
'maxOverflowRetries',
|
||||
] as const
|
||||
|
||||
/** Complete public top-level configuration key set. */
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
...POLICY_CONFIG_KEYS,
|
||||
'modelPolicies',
|
||||
'auto',
|
||||
])
|
||||
|
||||
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||
function validateConfigKeys(config: BasicCompactConfig): void {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: unknown key "${key}" `
|
||||
+ '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, '
|
||||
+ 'maxTokens, compactionRetries, maxOverflowRetries, auto)',
|
||||
)
|
||||
}
|
||||
/** Complete exact-target override key set. */
|
||||
const MODEL_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
'provider',
|
||||
'model',
|
||||
...POLICY_CONFIG_KEYS,
|
||||
])
|
||||
|
||||
/** Target-specific pressure configuration failure eligible for warning suppression. */
|
||||
export class TargetPressureConfigError extends Error {
|
||||
/**
|
||||
* @param targetKey - exact provider/model route used as the warning key.
|
||||
* @param message - actionable configuration failure detail.
|
||||
*/
|
||||
constructor(readonly targetKey: string, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults and validate the service-wide compaction policy.
|
||||
* @param config - raw compact-basic configuration.
|
||||
* @param tokenMeter - token meter supplying the context capacity.
|
||||
* @returns a detached deeply immutable configuration.
|
||||
* Resolve and validate service defaults plus exact-target partial overrides.
|
||||
* @param config - untrusted plugin configuration after Loader normalization.
|
||||
* @returns detached immutable defaults and validated exact-target overrides.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
config: BasicCompactConfig = {},
|
||||
tokenMeter: TokenMeterService,
|
||||
): ResolvedConfig {
|
||||
validateConfigKeys(config)
|
||||
export function resolveConfig(config: BasicCompactConfig = {}): ResolvedConfig {
|
||||
validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactConfig')
|
||||
validatePolicy(config, 'BasicCompactConfig')
|
||||
if (config.auto !== undefined && typeof config.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
|
||||
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retainTokens = config.retainTokens
|
||||
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||
const resolved: ResolvedConfig = {
|
||||
const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO })
|
||||
validateRatioRetention(thresholdRatio, retention, 'BasicCompactConfig')
|
||||
const modelPolicies = resolveModelPolicies(config.modelPolicies)
|
||||
for (const [index, policy] of modelPolicies.entries()) {
|
||||
validateRatioRetention(
|
||||
policy.thresholdRatio ?? thresholdRatio,
|
||||
resolveRetention(policy, retention),
|
||||
`BasicCompactConfig: modelPolicies[${index}]`,
|
||||
)
|
||||
}
|
||||
|
||||
return deepFreeze({
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
...retention,
|
||||
summarizationProvider: config.summarizationProvider ?? '',
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
maxOverflowRetries: config.maxOverflowRetries ?? 1,
|
||||
modelPolicies,
|
||||
auto: config.auto ?? true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio)
|
||||
if (resolved.retainTokens >= thresholdTokens) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
/**
|
||||
* Merge the exact provider/model override over the validated default policy.
|
||||
* @param config - validated service defaults and override table.
|
||||
* @param target - exact durable provider/model route to match.
|
||||
* @returns detached immutable policy before model-capacity scaling.
|
||||
*/
|
||||
export function resolveTargetPolicy(
|
||||
config: ResolvedConfig,
|
||||
target: Pick<LlmCallConfig, 'provider' | 'model'>,
|
||||
): ResolvedTargetPolicy {
|
||||
const override = config.modelPolicies.find(policy => (
|
||||
policy.provider === target.provider && policy.model === target.model
|
||||
))
|
||||
const inheritedRetention: ResolvedRetention = config.retainTokens === undefined
|
||||
? { retainRatio: config.retainRatio }
|
||||
: { retainTokens: config.retainTokens }
|
||||
return deepFreeze({
|
||||
target: { provider: target.provider, model: target.model },
|
||||
thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio,
|
||||
...resolveRetention(override ?? {}, inheritedRetention),
|
||||
summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider,
|
||||
summarizationModel: override?.summarizationModel ?? config.summarizationModel,
|
||||
maxTokens: override?.maxTokens ?? config.maxTokens,
|
||||
compactionRetries: override?.compactionRetries ?? config.compactionRetries,
|
||||
maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale one routed policy into concrete token budgets for its model capacity.
|
||||
* @param policy - merged policy for the exact routed target.
|
||||
* @param contextWindow - positive adapter-owned capacity for that target.
|
||||
* @returns detached immutable pressure and retention budgets.
|
||||
*/
|
||||
export function resolveCompactSpec(
|
||||
policy: ResolvedTargetPolicy,
|
||||
contextWindow: number,
|
||||
): ResolvedCompactSpec {
|
||||
const targetKey = `${policy.target.provider}/${policy.target.model}`
|
||||
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
throw new TargetPressureConfigError(
|
||||
targetKey,
|
||||
`BasicCompactConfig: contextWindow (${contextWindow}) must be a positive integer`,
|
||||
)
|
||||
}
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
||||
if (typeof resolved.summarizationProvider !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider must be a string')
|
||||
}
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string')
|
||||
}
|
||||
if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) {
|
||||
throw new Error(
|
||||
'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty',
|
||||
const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio)
|
||||
const retainTokens = policy.retainTokens === undefined
|
||||
? Math.floor(contextWindow * policy.retainRatio)
|
||||
: policy.retainTokens
|
||||
if (retainTokens >= thresholdTokens) {
|
||||
throw new TargetPressureConfigError(
|
||||
targetKey,
|
||||
`BasicCompactConfig: ${policy.target.provider}/${policy.target.model} retainTokens `
|
||||
+ `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
)
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
return deepFreeze(resolved)
|
||||
return deepFreeze({
|
||||
target: { ...policy.target },
|
||||
contextWindow,
|
||||
thresholdRatio: policy.thresholdRatio,
|
||||
thresholdTokens,
|
||||
retainTokens,
|
||||
summarizationProvider: policy.summarizationProvider,
|
||||
summarizationModel: policy.summarizationModel,
|
||||
maxTokens: policy.maxTokens,
|
||||
compactionRetries: policy.compactionRetries,
|
||||
maxOverflowRetries: policy.maxOverflowRetries,
|
||||
})
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`)
|
||||
/** Choose an explicit retention form or inherit the already-resolved fallback. */
|
||||
function resolveRetention(
|
||||
config: CompactPolicyConfig,
|
||||
fallback: ResolvedRetention,
|
||||
): ResolvedRetention {
|
||||
if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens }
|
||||
if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio }
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Reject a capacity-independent retention conflict at plugin load. */
|
||||
function validateRatioRetention(
|
||||
thresholdRatio: number,
|
||||
retention: ResolvedRetention,
|
||||
name: string,
|
||||
): void {
|
||||
if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) {
|
||||
throw new Error(
|
||||
`${name}: retainRatio (${retention.retainRatio}) must be less than `
|
||||
+ `the resolved thresholdRatio (${thresholdRatio})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`)
|
||||
/** Validate, detach, and reject duplicate exact-target policies. */
|
||||
function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] {
|
||||
if (configured === undefined) return []
|
||||
if (!Array.isArray(configured)) {
|
||||
throw new Error('BasicCompactConfig: modelPolicies must be an array')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
return configured.map((source: unknown, index) => {
|
||||
const name = `BasicCompactConfig: modelPolicies[${index}]`
|
||||
assertModelPolicy(source, name)
|
||||
const key = `${source.provider}\u0000${source.model}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: duplicate model policy for ${source.provider}/${source.model}`,
|
||||
)
|
||||
}
|
||||
seen.add(key)
|
||||
return { ...source }
|
||||
})
|
||||
}
|
||||
|
||||
/** Validate one untrusted exact-target override and narrow its public type. */
|
||||
function assertModelPolicy(
|
||||
source: unknown,
|
||||
name: string,
|
||||
): asserts source is ModelCompactPolicyConfig {
|
||||
if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`)
|
||||
validateKeys(source, MODEL_POLICY_KEYS, name)
|
||||
assertNonEmptyString(`${name}.provider`, source.provider)
|
||||
assertNonEmptyString(`${name}.model`, source.model)
|
||||
validatePolicy(source, name)
|
||||
}
|
||||
|
||||
/** Validate the fields common to defaults and exact-target partial overrides. */
|
||||
function validatePolicy(
|
||||
config: CompactPolicyConfig | Record<string, unknown>,
|
||||
name: string,
|
||||
): void {
|
||||
const thresholdRatio = config.thresholdRatio
|
||||
const retainRatio = config.retainRatio
|
||||
const retainTokens = config.retainTokens
|
||||
const maxTokens = config.maxTokens
|
||||
const compactionRetries = config.compactionRetries
|
||||
const maxOverflowRetries = config.maxOverflowRetries
|
||||
if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio)
|
||||
if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio)
|
||||
if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens)
|
||||
if (retainRatio !== undefined && retainTokens !== undefined) {
|
||||
throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`)
|
||||
}
|
||||
if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens)
|
||||
if (compactionRetries !== undefined) {
|
||||
assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries)
|
||||
}
|
||||
if (maxOverflowRetries !== undefined) {
|
||||
assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries)
|
||||
}
|
||||
|
||||
validateSummarizationPair(config, name)
|
||||
}
|
||||
|
||||
/** Require one scope to omit, clear, or replace the summarization target as a pair. */
|
||||
function validateSummarizationPair(
|
||||
config: CompactPolicyConfig | Record<string, unknown>,
|
||||
name: string,
|
||||
): void {
|
||||
const provider = config.summarizationProvider
|
||||
const model = config.summarizationModel
|
||||
if (provider !== undefined && typeof provider !== 'string') {
|
||||
throw new Error(`${name}.summarizationProvider must be a string`)
|
||||
}
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
throw new Error(`${name}.summarizationModel must be a string`)
|
||||
}
|
||||
if (provider === undefined && model === undefined) return
|
||||
if (provider === undefined || model === undefined
|
||||
|| (provider.length === 0) !== (model.length === 0)) {
|
||||
throw new Error(
|
||||
`${name}: summarizationProvider and summarizationModel must be set together `
|
||||
+ 'as an empty or non-empty pair',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||
function validateKeys(config: object, keys: ReadonlySet<string>, name: string): void {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertNonEmptyString(name: string, value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${name} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} (${String(value)}) must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${name} (${String(value)}) must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`)
|
||||
throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,29 +10,79 @@ import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: makes the optional sibling service available to `ctx.get()`.
|
||||
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import { resolveConfig } from './config.ts'
|
||||
import {
|
||||
resolveCompactSpec,
|
||||
resolveConfig,
|
||||
resolveTargetPolicy,
|
||||
TargetPressureConfigError,
|
||||
} from './config.ts'
|
||||
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
import type { SummarizationInput } from './summarizer.ts'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
BasicCompactConfig,
|
||||
CompactPolicyConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedCompactSpec,
|
||||
ResolvedConfig,
|
||||
ResolvedRetention,
|
||||
ResolvedTargetPolicy,
|
||||
} from './types.ts'
|
||||
|
||||
/** Resolve the exact model durably routed for the latest provider request. */
|
||||
function routedModel(session: Session): string | undefined {
|
||||
const model = session.requestHeader()?.config.model
|
||||
return model === undefined || model.length === 0 ? undefined : model
|
||||
/** Resolve the exact provider/model durably routed for the latest request. */
|
||||
function routedTarget(
|
||||
session: Session,
|
||||
): Pick<LlmCallConfig, 'provider' | 'model'> | undefined {
|
||||
const config = session.requestHeader()?.config
|
||||
if (config === undefined || config.provider.length === 0 || config.model.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return { provider: config.provider, model: config.model }
|
||||
}
|
||||
|
||||
/** Resolve the conversation target used to select an optional policy override. */
|
||||
function conversationTarget(
|
||||
agent: Agent,
|
||||
): Pick<LlmCallConfig, 'provider' | 'model'> | undefined {
|
||||
const routed = routedTarget(agent.session)
|
||||
if (routed !== undefined) return routed
|
||||
if (agent.options.provider === undefined || agent.options.provider.length === 0
|
||||
|| agent.options.model === undefined || agent.options.model.length === 0) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
|
||||
const thresholdRatioSchema = z.number()
|
||||
const retainRatioSchema = z.number()
|
||||
const retainTokensSchema = z.number().step(1).min(0)
|
||||
const summarizationProviderSchema = z.string()
|
||||
const summarizationModelSchema = z.string()
|
||||
const maxTokensSchema = z.number().step(1).min(1)
|
||||
const compactionRetriesSchema = z.number().step(1).min(0)
|
||||
const maxOverflowRetriesSchema = z.number().step(1).min(0)
|
||||
|
||||
const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
thresholdRatio: thresholdRatioSchema,
|
||||
retainRatio: retainRatioSchema,
|
||||
retainTokens: retainTokensSchema,
|
||||
summarizationProvider: summarizationProviderSchema,
|
||||
summarizationModel: summarizationModelSchema,
|
||||
maxTokens: maxTokensSchema,
|
||||
compactionRetries: compactionRetriesSchema,
|
||||
maxOverflowRetries: maxOverflowRetriesSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
|
||||
* retention, provenance, and summary-convergence pricing.
|
||||
@@ -45,22 +95,26 @@ export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm', 'tokenMeter']
|
||||
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
thresholdRatio: z.number().default(0.8),
|
||||
retainTokens: z.number().step(1),
|
||||
summarizationProvider: z.string().default(''),
|
||||
summarizationModel: z.string().default(''),
|
||||
maxTokens: z.number().step(1).min(1).default(8192),
|
||||
compactionRetries: z.number().step(1).min(0).default(1),
|
||||
maxOverflowRetries: z.number().step(1).min(0).default(1),
|
||||
auto: z.boolean().default(true),
|
||||
thresholdRatio: thresholdRatioSchema,
|
||||
retainRatio: retainRatioSchema,
|
||||
retainTokens: retainTokensSchema,
|
||||
summarizationProvider: summarizationProviderSchema,
|
||||
summarizationModel: summarizationModelSchema,
|
||||
maxTokens: maxTokensSchema,
|
||||
compactionRetries: compactionRetriesSchema,
|
||||
maxOverflowRetries: maxOverflowRetriesSchema,
|
||||
modelPolicies: z.array(modelPolicy),
|
||||
auto: z.boolean(),
|
||||
})
|
||||
|
||||
/** Resolved and validated compaction configuration. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
private readonly warnedPressureConfigTargets = new Set<string>()
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config, ctx.tokenMeter)
|
||||
this.config = resolveConfig(config)
|
||||
if (this.config.auto) this._registerAutomaticCompaction()
|
||||
}
|
||||
|
||||
@@ -90,16 +144,33 @@ export class BasicCompactService extends CompactService {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
if (result !== null) logResult(result, 'post-step pressure')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TargetPressureConfigError) {
|
||||
if (this.warnedPressureConfigTargets.has(error.targetKey)) return
|
||||
this.warnedPressureConfigTargets.add(error.targetKey)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/request-error', async (agent, _turn, _step, _error, failure, priorFailures, signal, next) => {
|
||||
const priorOverflowFailures = priorFailures.filter(item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE).length
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE
|
||||
|| priorOverflowFailures >= this.config.maxOverflowRetries
|
||||
|| signal.aborted) return next()
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
failure,
|
||||
priorFailures,
|
||||
signal,
|
||||
next,
|
||||
) => {
|
||||
const priorOverflowFailures = priorFailures.filter(
|
||||
item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
).length
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
|
||||
const target = routedTarget(agent.session)
|
||||
if (target === undefined) return next()
|
||||
const policy = resolveTargetPolicy(this.config, target)
|
||||
if (priorOverflowFailures >= policy.maxOverflowRetries) return next()
|
||||
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
let result: CompactionResult | null
|
||||
@@ -135,19 +206,25 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a rendered region through a direct one-shot `ctx.llm.stream()`
|
||||
* call. Override this sole hook for a template or remote summarizer.
|
||||
* @param text - plain-text conversation region to condense.
|
||||
* Summarize the replayed conversation region through a direct one-shot
|
||||
* `ctx.llm.stream()` call whose prefix reuses the conversation's own system
|
||||
* prompt, tools, and messages so the provider's KV cache is not invalidated.
|
||||
* Override this sole hook for a template or remote summarizer.
|
||||
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
||||
*/
|
||||
protected async summarize(
|
||||
text: string,
|
||||
input: SummarizationInput,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
|
||||
const target = conversationTarget(agent)
|
||||
const config = target === undefined
|
||||
? this.config
|
||||
: resolveTargetPolicy(this.config, target)
|
||||
return summarizeWithLlm(this.ctx, config, input, agent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,16 +242,15 @@ export class BasicCompactService extends CompactService {
|
||||
trigger: CompactionTrigger,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const model = routedModel(agent.session)
|
||||
if (model === undefined) return null
|
||||
const target = routedTarget(agent.session)
|
||||
if (target === undefined) return null
|
||||
const policy = resolveTargetPolicy(this.config, target)
|
||||
const meter = this.ctx.tokenMeter
|
||||
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||
let measurement = meter.measure(agent.session)
|
||||
switch (trigger) {
|
||||
case 'context-overflow':
|
||||
break
|
||||
case 'pressure':
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
@@ -182,25 +258,43 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
// Pruning is optional so compact-basic remains independently composable.
|
||||
// Once either trigger qualifies, land the model-free pass before choosing
|
||||
// a summary range, then remeasure through the singleton replay fold.
|
||||
// Overflow always qualifies; pressure first resolves the routed model's
|
||||
// capacity and checks its target-specific threshold.
|
||||
const prune = this.ctx.get('toolResultPrune')
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
|
||||
if (trigger === 'context-overflow') {
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
const range = selectCompactableRange(agent.session, measurement, 0)
|
||||
if (range === null) return null
|
||||
return this.compactRegion(range.start, range.end, agent, signal)
|
||||
}
|
||||
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
const context = await this.ctx.llm.resolveModelContext(target.provider, target.model)
|
||||
const targetKey = `${target.provider}/${target.model}`
|
||||
if (context === undefined) {
|
||||
throw new TargetPressureConfigError(
|
||||
targetKey,
|
||||
`compact-basic: no context capacity for ${targetKey}; `
|
||||
+ 'configure contextWindow on that adapter model',
|
||||
)
|
||||
}
|
||||
const spec = resolveCompactSpec(policy, context.contextWindow)
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return null
|
||||
|
||||
// Once pressure qualifies, land the model-free pass before choosing a
|
||||
// summary range, then remeasure through the singleton replay fold.
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return null
|
||||
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
||||
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
|
||||
for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) {
|
||||
const range = selectCompactableRange(agent.session, measurement, spec.retainTokens)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||
if (result === null) return null
|
||||
@@ -209,12 +303,12 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||
measurement = meter.measure(agent.session)
|
||||
if (measurement.totalTokens < threshold) return result
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return result
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
|
||||
+ `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
`compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts `
|
||||
+ `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,7 +330,7 @@ export class BasicCompactService extends CompactService {
|
||||
const session = agent.session
|
||||
return compactSurfaceRegion({
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
|
||||
}, session, start, end, agent, signal)
|
||||
}
|
||||
}
|
||||
|
||||
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 */
|
||||
@@ -5,20 +5,20 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
renderTranscript,
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { frameSummary } from './summarizer.ts'
|
||||
import type { SummaryResult } from './summarizer.ts'
|
||||
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
|
||||
|
||||
interface RegionDependencies {
|
||||
readonly meter: TokenMeterService
|
||||
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +122,8 @@ export async function compactSurfaceRegion(
|
||||
throw new Error('compaction: selected surface changed before summarization began')
|
||||
}
|
||||
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
@@ -173,6 +173,34 @@ export async function compactSurfaceRegion(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the last routed request's cacheable prefix for the shadowed
|
||||
* region: its system prompt and tool schemas, then the request-only message
|
||||
* prefix followed by the region's own derived messages in surface order. The
|
||||
* summarizer appends only the compaction instruction after this, so the call
|
||||
* is a genuine prefix of the conversation and reuses the provider's KV cache.
|
||||
* @param session - session supplying the request header and per-node projection.
|
||||
* @param shadowedSeqs - the surface-node seqs, in order, being compacted.
|
||||
* @returns the replayed conversation prefix to condense.
|
||||
*/
|
||||
function buildSummarizationInput(
|
||||
session: Session,
|
||||
shadowedSeqs: readonly number[],
|
||||
): SummarizationInput {
|
||||
const header = session.requestHeader()
|
||||
const events = session.events
|
||||
const regionMessages = shadowedSeqs
|
||||
// shadowedSeqs are current surface seqs, so each is a valid log index.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map(seq => session.deriveEventMessage(events[seq]!))
|
||||
.filter((message): message is Message => message !== null)
|
||||
return {
|
||||
...header?.system === undefined ? {} : { system: header.system },
|
||||
...header?.tools === undefined ? {} : { tools: header.tools },
|
||||
messages: [...header?.messagePrefix ?? [], ...regionMessages],
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the current turn boundary and latest compaction bracket once. */
|
||||
function inspectTurnTail(
|
||||
events: readonly SessionEvent[],
|
||||
|
||||
@@ -6,17 +6,28 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ResolvedConfig } from './types.ts'
|
||||
|
||||
interface SummaryConfig {
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
}
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/** Fixed structure required from the auxiliary summarization call. */
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
/**
|
||||
* The summarization directive, delivered as the FINAL user message after the
|
||||
* replayed conversation rather than as a distinct summarizer system prompt.
|
||||
* Keeping the conversation's own system prompt, tools, and message prefix in
|
||||
* front of it makes the auxiliary call a genuine prefix of the last routed
|
||||
* request, so the provider's KV cache is reused instead of invalidated.
|
||||
*/
|
||||
const COMPACTION_INSTRUCTION = [
|
||||
'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
@@ -47,14 +58,30 @@ const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
'- Do NOT mention this summarization request or that the context was compacted.',
|
||||
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
||||
`- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes the replacement user message established context. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* The replayed conversation surface the summarizer condenses. Reproducing the
|
||||
* last routed request's system prompt, tools, and leading messages verbatim
|
||||
* lets the auxiliary call reuse the provider's warm prefix cache; the trailing
|
||||
* compaction instruction is then the only novel input.
|
||||
*/
|
||||
export interface SummarizationInput {
|
||||
/** The conversation's own system prompt, reused for prefix-cache alignment; absent for a system-less request. */
|
||||
readonly system?: string
|
||||
/** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */
|
||||
readonly tools?: readonly ToolSchema[]
|
||||
/** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */
|
||||
readonly messages: readonly Message[]
|
||||
}
|
||||
|
||||
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
|
||||
export interface SummaryResult {
|
||||
summary: ContentBlock[]
|
||||
@@ -64,18 +91,20 @@ export interface SummaryResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the default direct `ctx.llm.stream()` summarization call.
|
||||
* Run the default cache-reusing `ctx.llm.stream()` summarization call: replay
|
||||
* the conversation prefix, then append the compaction instruction as the final
|
||||
* user message so the provider's warm prefix cache is reused.
|
||||
* @param ctx - context providing the LLM service.
|
||||
* @param config - resolved backend configuration.
|
||||
* @param text - rendered transcript region to summarize.
|
||||
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text-only summary blocks and exact call provenance.
|
||||
*/
|
||||
export async function summarizeWithLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedConfig,
|
||||
text: string,
|
||||
config: SummaryConfig,
|
||||
input: SummarizationInput,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
@@ -97,14 +126,16 @@ export async function summarizeWithLlm(
|
||||
}
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const messages: Message[] = [
|
||||
...input.messages,
|
||||
{ role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }] },
|
||||
]
|
||||
const options: GenerateOptions = {
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
messages,
|
||||
...input.system === undefined ? {} : { system: input.system },
|
||||
...input.tools === undefined ? {} : { tools: [...input.tools] },
|
||||
maxTokens: config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
...signal === undefined ? {} : { signal },
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
* @module @deepseek-ai/dsh-compact-basic/types
|
||||
*/
|
||||
|
||||
/** Basic compaction configuration; every common field has a deployment default. */
|
||||
export interface BasicCompactConfig {
|
||||
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Policy fields shared by the default policy and exact model overrides. */
|
||||
export interface CompactPolicyConfig {
|
||||
/** Compact at this fraction of the model's context window. Defaults to `0.8`. */
|
||||
thresholdRatio?: number
|
||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||
/** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */
|
||||
retainRatio?: number
|
||||
/** Absolute recent-context budget; mutually exclusive with `retainRatio`. */
|
||||
retainTokens?: number
|
||||
/** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
/** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */
|
||||
summarizationProvider?: string
|
||||
/** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
/** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
maxTokens?: number
|
||||
@@ -20,18 +24,53 @@ export interface BasicCompactConfig {
|
||||
compactionRetries?: number
|
||||
/** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */
|
||||
maxOverflowRetries?: number
|
||||
}
|
||||
|
||||
/** Exact provider/model override merged over the default compaction policy. */
|
||||
export interface ModelCompactPolicyConfig extends CompactPolicyConfig {
|
||||
/** Registered provider route to match. */
|
||||
provider: string
|
||||
/** Exact routed model id to match within `provider`. */
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Basic compaction configuration with an optional exact-target policy table. */
|
||||
export interface BasicCompactConfig extends CompactPolicyConfig {
|
||||
/** Exact provider/model overrides; duplicate targets fail plugin load. */
|
||||
modelPolicies?: ModelCompactPolicyConfig[]
|
||||
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
/** Validated and detached compaction configuration. */
|
||||
export interface ResolvedConfig {
|
||||
/** Exactly one validated retention form. */
|
||||
export type ResolvedRetention =
|
||||
| { readonly retainRatio: number; readonly retainTokens?: never }
|
||||
| { readonly retainRatio?: never; readonly retainTokens: number }
|
||||
|
||||
/** Validated policy fields shared before and after exact-target matching. */
|
||||
interface ResolvedPolicyFields {
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
readonly maxOverflowRetries: number
|
||||
}
|
||||
|
||||
/** Validated immutable config whose target-specific defaults remain unresolved. */
|
||||
export type ResolvedConfig = ResolvedPolicyFields & ResolvedRetention & {
|
||||
readonly modelPolicies: readonly Readonly<ModelCompactPolicyConfig>[]
|
||||
readonly auto: boolean
|
||||
}
|
||||
|
||||
/** Fully merged policy for one routed conversation target, before capacity scaling. */
|
||||
export type ResolvedTargetPolicy = ResolvedPolicyFields & ResolvedRetention & {
|
||||
readonly target: Pick<LlmCallConfig, 'provider' | 'model'>
|
||||
}
|
||||
|
||||
/** One routed model's concrete pressure and retention budget. */
|
||||
export type ResolvedCompactSpec = Omit<ResolvedTargetPolicy, 'retainRatio' | 'retainTokens'> & {
|
||||
readonly contextWindow: number
|
||||
readonly thresholdTokens: number
|
||||
readonly retainTokens: number
|
||||
}
|
||||
|
||||
@@ -3,22 +3,65 @@ import { Context } from 'cordis'
|
||||
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
|
||||
import {
|
||||
resolveCompactSpec,
|
||||
resolveConfig,
|
||||
resolveTargetPolicy,
|
||||
} from '@deepseek-ai/dsh-compact-basic/src/config.ts'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
Message,
|
||||
StreamChunk,
|
||||
} 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'
|
||||
|
||||
class ContextAdapter extends LlmAdapter {
|
||||
constructor(private readonly contextWindow: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<LlmModelContext> {
|
||||
return Promise.resolve({ contextWindow: this.contextWindow })
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
class RoutedContextAdapter extends LlmAdapter {
|
||||
constructor(private readonly windows: Readonly<Record<string, number>>) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(provider: string): Promise<LlmModelContext | undefined> {
|
||||
const contextWindow = this.windows[provider]
|
||||
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(contextWindow = 1_000): Context {
|
||||
const ctx = new Context()
|
||||
void new TokenMeterService(ctx, { contextWindow })
|
||||
void new LlmService(ctx)
|
||||
void new TokenMeterService(ctx)
|
||||
ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow))
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -26,6 +69,21 @@ function agent(session: Session, model?: string): Agent {
|
||||
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
|
||||
}
|
||||
|
||||
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
|
||||
function summarizedText(input: SummarizationInput): string {
|
||||
const collect = (blocks: readonly ContentBlock[]): string =>
|
||||
blocks.map(block =>
|
||||
block.type === 'text' ? block.text
|
||||
: block.type === 'tool-result' ? collect(block.content)
|
||||
: '').join('\n')
|
||||
return input.messages.map(message => collect(message.content)).join('\n')
|
||||
}
|
||||
|
||||
/** A minimal replayed prefix carrying one user message of the given text. */
|
||||
function promptInput(text: string): SummarizationInput {
|
||||
return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] }
|
||||
}
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
@@ -141,14 +199,14 @@ class TestCompactService extends BasicCompactService {
|
||||
summaryModel = 'summary-model'
|
||||
error: unknown
|
||||
mutateDuringSummary: (() => void) | undefined
|
||||
calls: Array<{ text: string; signal: AbortSignal | undefined }> = []
|
||||
calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = []
|
||||
|
||||
override async summarize(
|
||||
text: string,
|
||||
input: SummarizationInput,
|
||||
_agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
this.calls.push({ text, signal })
|
||||
this.calls.push({ input, signal })
|
||||
this.mutateDuringSummary?.()
|
||||
if (this.error !== undefined) throw this.error
|
||||
return {
|
||||
@@ -178,43 +236,131 @@ async function compactIfNeeded(
|
||||
|
||||
describe('compact configuration and defaults', () => {
|
||||
it('uses low-friction service-wide defaults', () => {
|
||||
const ctx = createContext()
|
||||
const resolved = resolveConfig({}, ctx.tokenMeter)
|
||||
const resolved = resolveConfig({})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 160,
|
||||
retainRatio: 0.16,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
maxOverflowRetries: 1,
|
||||
modelPolicies: [],
|
||||
auto: true,
|
||||
})
|
||||
expect(Object.isFrozen(resolved)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves threshold and retention overrides independently', () => {
|
||||
const ctx = createContext()
|
||||
const thresholdOnly = resolveConfig({
|
||||
thresholdRatio: 0.5,
|
||||
}, ctx.tokenMeter)
|
||||
})
|
||||
expect(thresholdOnly).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 160,
|
||||
retainRatio: 0.16,
|
||||
})
|
||||
|
||||
const retentionOnly = resolveConfig({
|
||||
retainTokens: 70,
|
||||
}, ctx.tokenMeter)
|
||||
})
|
||||
expect(retentionOnly).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 70,
|
||||
})
|
||||
expect(retentionOnly).not.toHaveProperty('retainRatio')
|
||||
})
|
||||
|
||||
it('merges exact provider/model policy overrides and scales ratios per model', () => {
|
||||
const config = resolveConfig({
|
||||
thresholdRatio: 0.8,
|
||||
retainRatio: 0.1,
|
||||
modelPolicies: [{
|
||||
provider: 'small-provider',
|
||||
model: 'shared-id',
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 120,
|
||||
}],
|
||||
})
|
||||
const small = resolveTargetPolicy(config, {
|
||||
provider: 'small-provider',
|
||||
model: 'shared-id',
|
||||
})
|
||||
const otherProvider = resolveTargetPolicy(config, {
|
||||
provider: 'large-provider',
|
||||
model: 'shared-id',
|
||||
})
|
||||
|
||||
expect(resolveCompactSpec(small, 1_000)).toMatchObject({
|
||||
thresholdTokens: 500,
|
||||
retainTokens: 120,
|
||||
})
|
||||
expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({
|
||||
thresholdTokens: 1_600,
|
||||
retainTokens: 200,
|
||||
})
|
||||
|
||||
const ratioOverride = resolveTargetPolicy(resolveConfig({
|
||||
retainTokens: 200,
|
||||
modelPolicies: [{
|
||||
provider: 'ratio-provider',
|
||||
model: 'ratio-model',
|
||||
thresholdRatio: 0.6,
|
||||
retainRatio: 0.2,
|
||||
summarizationProvider: 'summary-provider',
|
||||
summarizationModel: 'summary-model',
|
||||
maxTokens: 512,
|
||||
compactionRetries: 2,
|
||||
maxOverflowRetries: 3,
|
||||
}],
|
||||
}), { provider: 'ratio-provider', model: 'ratio-model' })
|
||||
expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({
|
||||
thresholdTokens: 1_200,
|
||||
retainTokens: 400,
|
||||
summarizationProvider: 'summary-provider',
|
||||
summarizationModel: 'summary-model',
|
||||
maxTokens: 512,
|
||||
compactionRetries: 2,
|
||||
maxOverflowRetries: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('inherits, clears, and replaces the summarization target as a pair', () => {
|
||||
const config = resolveConfig({
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
modelPolicies: [
|
||||
{ provider: 'inherit-provider', model: MODEL },
|
||||
{
|
||||
provider: 'clear-provider',
|
||||
model: MODEL,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
},
|
||||
{
|
||||
provider: 'replace-provider',
|
||||
model: MODEL,
|
||||
summarizationProvider: 'replacement-provider',
|
||||
summarizationModel: 'replacement-model',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL }))
|
||||
.toMatchObject({
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
})
|
||||
expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL }))
|
||||
.toMatchObject({ summarizationProvider: '', summarizationModel: '' })
|
||||
expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL }))
|
||||
.toMatchObject({
|
||||
summarizationProvider: 'replacement-provider',
|
||||
summarizationModel: 'replacement-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('validates common values and pressure-policy invariants', () => {
|
||||
const ctx = createContext()
|
||||
const bad = [
|
||||
[{ maxTokens: 0 }, /maxTokens/],
|
||||
[{ compactionRetries: -1 }, /compactionRetries/],
|
||||
@@ -222,20 +368,62 @@ describe('compact configuration and defaults', () => {
|
||||
[{ auto: 'yes' }, /auto must be a boolean/],
|
||||
[{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
|
||||
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
||||
[{ summarizationProvider: MODEL }, /must both be set or both be empty/],
|
||||
[{ summarizationModel: MODEL }, /must both be set or both be empty/],
|
||||
[{ summarizationProvider: MODEL }, /must be set together/],
|
||||
[{ summarizationModel: MODEL }, /must be set together/],
|
||||
[{ summarizationProvider: '' }, /must be set together/],
|
||||
[{ summarizationModel: '' }, /must be set together/],
|
||||
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
||||
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
||||
[{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/],
|
||||
[{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/],
|
||||
[{ retainTokens: -1 }, /non-negative integer/],
|
||||
[{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
|
||||
[{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/],
|
||||
[{ modelPolicies: {} }, /modelPolicies must be an array/],
|
||||
[{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/],
|
||||
[{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/],
|
||||
[{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/],
|
||||
[{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/],
|
||||
[{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/],
|
||||
[{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/],
|
||||
[{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/],
|
||||
[{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/],
|
||||
[{
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }],
|
||||
}, /modelPolicies\[0\].*must be set together/],
|
||||
[{
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }],
|
||||
}, /modelPolicies\[0\].*must be set together/],
|
||||
[{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/],
|
||||
[
|
||||
{ modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] },
|
||||
/modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/,
|
||||
],
|
||||
[
|
||||
{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] },
|
||||
/modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/,
|
||||
],
|
||||
[{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/],
|
||||
[{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/],
|
||||
[{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/],
|
||||
] as Array<[unknown, RegExp]>
|
||||
|
||||
for (const [config, pattern] of bad) {
|
||||
expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
|
||||
expect(() => resolveConfig(config as BasicCompactConfig)).toThrow(pattern)
|
||||
}
|
||||
|
||||
const invalidPressure = resolveTargetPolicy(resolveConfig({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 500,
|
||||
}), { provider: MODEL, model: MODEL })
|
||||
expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/)
|
||||
expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/)
|
||||
expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('pressure measurement and retention', () => {
|
||||
@@ -254,7 +442,7 @@ describe('pressure measurement and retention', () => {
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('meters any routed model without profile resolution', async () => {
|
||||
it('meters an unlisted model when its provider adapter supplies context metadata', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = conversation()
|
||||
session.append('request/header', {
|
||||
@@ -265,6 +453,52 @@ describe('pressure measurement and retention', () => {
|
||||
.resolves.not.toBeNull()
|
||||
})
|
||||
|
||||
it('re-resolves capacity after a same-model-id provider switch in one session', async () => {
|
||||
const ctx = new Context()
|
||||
void new LlmService(ctx)
|
||||
void new TokenMeterService(ctx)
|
||||
ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({
|
||||
large: 10_000,
|
||||
small: 1_000,
|
||||
}))
|
||||
const compact = service({
|
||||
auto: false,
|
||||
thresholdRatio: 0.5,
|
||||
retainRatio: 0.1,
|
||||
}, ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'large', model: 'shared-id' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
await expect(compactIfNeeded(compact, session)).resolves.toBeNull()
|
||||
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'small', model: 'shared-id' } },
|
||||
reason: 'change',
|
||||
})
|
||||
await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull()
|
||||
})
|
||||
|
||||
it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => {
|
||||
const ctx = new Context()
|
||||
void new LlmService(ctx)
|
||||
void new TokenMeterService(ctx)
|
||||
ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
|
||||
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
|
||||
const compact = service(compactConfig, ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'unknown-context', model: 'model' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
|
||||
await expect(compactIfNeeded(compact, session, 'pressure'))
|
||||
.rejects.toThrow(/no context capacity for unknown-context\/model/)
|
||||
await expect(compactIfNeeded(compact, session, 'context-overflow'))
|
||||
.resolves.not.toBeNull()
|
||||
})
|
||||
|
||||
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('single-tool-pair'))
|
||||
@@ -503,8 +737,8 @@ describe('optional model-free tool-result pruning', () => {
|
||||
|
||||
expect(await compactIfNeeded(compact, session)).not.toBeNull()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
|
||||
expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300))
|
||||
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
|
||||
expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300))
|
||||
})
|
||||
|
||||
it('retains the original compact-basic behavior without the optional plugin', async () => {
|
||||
@@ -541,7 +775,7 @@ describe('compaction region transaction', () => {
|
||||
expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
|
||||
expect(result.shadowedTokenCount).toBeGreaterThan(0)
|
||||
expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
|
||||
expect(compact.calls[0]?.text).toContain('fixture user 1')
|
||||
expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1')
|
||||
const summary = session.events.findLast(event => event.type === 'compact/summary')
|
||||
expect(summary?.data).toMatchObject({
|
||||
shadowedSeqs: result.shadowedSeqs,
|
||||
@@ -559,6 +793,25 @@ describe('compaction region transaction', () => {
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
})
|
||||
|
||||
it('replays the latest routed header prefix so the summarizer reuses the cache', async () => {
|
||||
const compact = service()
|
||||
const session = conversation(3)
|
||||
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
||||
const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }]
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix },
|
||||
reason: 'resume',
|
||||
})
|
||||
const nodes = session.surface.nodes
|
||||
await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL)
|
||||
|
||||
const { input } = compact.calls[0]!
|
||||
expect(input.system).toBe('CONVERSATION SYSTEM')
|
||||
expect(input.tools).toEqual(tools)
|
||||
expect(input.messages[0]).toEqual(messagePrefix[0])
|
||||
expect(summarizedText(input)).toContain('fixture user 1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start missing', 9_001, undefined, /start seq 9001 not found/],
|
||||
['end missing', undefined, 9_002, /end seq 9002 not found/],
|
||||
@@ -772,11 +1025,11 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
|
||||
class ExposedCompactService extends BasicCompactService {
|
||||
runSummarize(
|
||||
text: string,
|
||||
input: SummarizationInput,
|
||||
owner: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return this.summarize(text, owner, signal)
|
||||
return this.summarize(input, owner, signal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +1041,7 @@ async function summarizerHarness(
|
||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
||||
void new TokenMeterService(ctx)
|
||||
const adapter = new ScriptedAdapter(blocks, finish)
|
||||
ctx.llm.registerAdapter([model], adapter)
|
||||
const compact = new ExposedCompactService(ctx, config)
|
||||
@@ -808,7 +1061,7 @@ describe('default one-shot summarizer', () => {
|
||||
maxTokens: 321,
|
||||
})
|
||||
const session = conversation(1)
|
||||
const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL)
|
||||
const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL)
|
||||
|
||||
expect(output).toEqual({
|
||||
summary: [{ type: 'text', text: 'public summary' }],
|
||||
@@ -823,7 +1076,68 @@ describe('default one-shot summarizer', () => {
|
||||
signal: SIGNAL,
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
|
||||
const instruction = adapter.lastOptions?.messages.at(-1)?.content[0]
|
||||
expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
it('replays the conversation prefix and appends the instruction as the final message', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
||||
const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] }
|
||||
await compact.runSummarize({
|
||||
system: 'REPLAYED SYSTEM',
|
||||
tools,
|
||||
messages: [prefix],
|
||||
}, agent(conversation(1), MODEL))
|
||||
|
||||
expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM')
|
||||
expect(adapter.lastOptions?.tools).toEqual(tools)
|
||||
const messages = adapter.lastOptions?.messages ?? []
|
||||
expect(messages[0]).toEqual(prefix)
|
||||
const last = messages.at(-1)?.content[0]
|
||||
const lastText = last?.type === 'text' ? last.text : ''
|
||||
expect(lastText).toContain('Condense the conversation ABOVE')
|
||||
expect(lastText).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
it('applies the routed model policy without changing the replayed prefix', async () => {
|
||||
const { ctx, compact } = await summarizerHarness(
|
||||
[{ type: 'text', text: 'unused default summary' }],
|
||||
undefined,
|
||||
MODEL,
|
||||
{
|
||||
auto: false,
|
||||
maxTokens: 111,
|
||||
modelPolicies: [{
|
||||
provider: MODEL,
|
||||
model: MODEL,
|
||||
summarizationProvider: 'policy-summary',
|
||||
summarizationModel: 'policy-summary',
|
||||
maxTokens: 222,
|
||||
}],
|
||||
},
|
||||
)
|
||||
const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }])
|
||||
ctx.llm.registerAdapter(['policy-summary'], policyAdapter)
|
||||
const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'warm prefix' }] }
|
||||
|
||||
const output = await compact.runSummarize({
|
||||
system: 'WARM SYSTEM',
|
||||
messages: [prefix],
|
||||
}, agent(conversation(1), 'fallback'))
|
||||
|
||||
expect(output).toMatchObject({
|
||||
provider: 'policy-summary',
|
||||
model: 'policy-summary',
|
||||
maxTokens: 222,
|
||||
})
|
||||
expect(policyAdapter.lastOptions).toMatchObject({
|
||||
provider: 'policy-summary',
|
||||
model: 'policy-summary',
|
||||
maxTokens: 222,
|
||||
system: 'WARM SYSTEM',
|
||||
})
|
||||
expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix)
|
||||
})
|
||||
|
||||
it('resolves the latest routed provider/model before the AgentOptions pair', async () => {
|
||||
@@ -833,7 +1147,7 @@ describe('default one-shot summarizer', () => {
|
||||
header: { config: { provider: 'routed', model: 'routed' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const output = await compact.runSummarize('history', agent(session, 'fallback'))
|
||||
const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback'))
|
||||
expect(output.provider).toBe('routed')
|
||||
expect(output.model).toBe('routed')
|
||||
expect(adapter.lastOptions?.provider).toBe('routed')
|
||||
@@ -867,7 +1181,32 @@ describe('default one-shot summarizer', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx)
|
||||
const compact = new ExposedCompactService(ctx, { auto: false })
|
||||
await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
|
||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||
})
|
||||
|
||||
it('uses a complete AgentOptions target when no durable route exists', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const session = new Session(SessionId('headerless-summary'))
|
||||
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
|
||||
provider: MODEL,
|
||||
model: MODEL,
|
||||
})
|
||||
expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ provider: '', model: MODEL },
|
||||
{ provider: MODEL },
|
||||
{ provider: MODEL, model: '' },
|
||||
])('rejects incomplete AgentOptions target %#', async (options) => {
|
||||
const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
|
||||
const owner = {
|
||||
session: new Session(SessionId(`incomplete-${String(options.model)}`)),
|
||||
options,
|
||||
} as Agent
|
||||
await expect(compact.runSummarize(promptInput('history'), owner))
|
||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||
})
|
||||
|
||||
@@ -882,7 +1221,7 @@ describe('default one-shot summarizer', () => {
|
||||
const { compact } = await summarizerHarness([], finish)
|
||||
let thrown: unknown
|
||||
try {
|
||||
await compact.runSummarize('history', agent(conversation(1), MODEL))
|
||||
await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
@@ -894,14 +1233,14 @@ describe('default one-shot summarizer', () => {
|
||||
|
||||
it('rejects empty or reasoning-only successful output', async () => {
|
||||
const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
|
||||
await expect(compact.runSummarize('history', agent(conversation(1), MODEL)))
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
||||
.rejects.toThrow(/no text summary content/)
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
@@ -914,7 +1253,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 } {
|
||||
@@ -969,6 +1310,43 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('warns once per routed target when proactive pressure has no context metadata', async () => {
|
||||
const ctx = createContext()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
|
||||
void new TestCompactService(ctx, {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const session = conversation(4)
|
||||
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`),
|
||||
])
|
||||
})
|
||||
|
||||
it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => {
|
||||
const ctx = createContext()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
void new TestCompactService(ctx, {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 500,
|
||||
})
|
||||
const session = conversation(4)
|
||||
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'),
|
||||
])
|
||||
})
|
||||
|
||||
it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
void new TestCompactService(ctx, {
|
||||
@@ -1023,7 +1401,7 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
|
||||
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
|
||||
})
|
||||
|
||||
it('retries from a durable prune when later overflow summarization throws', async () => {
|
||||
@@ -1184,6 +1562,18 @@ describe('automatic listener and loader composition', () => {
|
||||
.toEqual({ action: 'retry' })
|
||||
})
|
||||
|
||||
it('delegates canonical overflow when no durable routed target exists', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx)
|
||||
const session = new Session(SessionId('headerless-overflow'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
|
||||
})
|
||||
|
||||
it('honors retry caps, non-context failures, and cancellation', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
|
||||
@@ -1199,6 +1589,23 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(compactSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the routed model override to the overflow retry cap', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, {
|
||||
maxOverflowRetries: 2,
|
||||
modelPolicies: [{
|
||||
provider: MODEL,
|
||||
model: MODEL,
|
||||
maxOverflowRetries: 1,
|
||||
}],
|
||||
})
|
||||
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
|
||||
|
||||
expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(compactSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry when cancellation lands during an awaited compaction', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx)
|
||||
@@ -1246,7 +1653,6 @@ describe('automatic listener and loader composition', () => {
|
||||
const meterFiber = await ctx.plugin(TokenMeterService)
|
||||
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
|
||||
|
||||
expect(ctx.tokenMeter.contextWindow).toBe(128_000)
|
||||
expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
await compactFiber.dispose()
|
||||
expect(ctx.get('compact')).toBeUndefined()
|
||||
@@ -1257,7 +1663,7 @@ describe('automatic listener and loader composition', () => {
|
||||
it('removes its automatic listener with the plugin fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
const fiber = await ctx.plugin(TestCompactService, {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
|
||||
@@ -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'
|
||||
@@ -38,6 +41,10 @@ class StepwiseToolAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<{ contextWindow: number }> {
|
||||
return Promise.resolve({ contextWindow: 400 })
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const n = this.calls
|
||||
this.calls += 1
|
||||
@@ -69,8 +76,17 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<{ contextWindow: number }> {
|
||||
return Promise.resolve({ contextWindow: 128 })
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.system?.includes('You are a compaction engine')) {
|
||||
// The cache-reusing summarizer replays the conversation prefix and marks
|
||||
// its call only by the compaction instruction in the trailing user message.
|
||||
const trailing = options.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
if (trailing.includes('acting as a compaction engine')) {
|
||||
this.summaryRequests.push(options)
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } }
|
||||
@@ -104,12 +120,19 @@ 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, { contextWindow: 400 })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
@@ -125,7 +148,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
auto: true,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
@@ -255,9 +277,9 @@ 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, { contextWindow: 128 })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
@@ -317,7 +339,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,
|
||||
@@ -325,7 +347,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
jitterRatio: 0,
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
|
||||
@@ -56,8 +56,6 @@ describe('real Loader composition', () => {
|
||||
const loaded = await loadYaml([
|
||||
"- name: '@deepseek-ai/dsh-llm'",
|
||||
"- name: '@deepseek-ai/dsh-token-meter'",
|
||||
' config:',
|
||||
' contextWindow: 4096',
|
||||
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
|
||||
' config:',
|
||||
' thresholdChars: 100',
|
||||
@@ -66,7 +64,7 @@ describe('real Loader composition', () => {
|
||||
"- name: '@deepseek-ai/dsh-compact-basic'",
|
||||
' config:',
|
||||
' thresholdRatio: 0.5',
|
||||
' retainTokens: 512',
|
||||
' retainRatio: 0.125',
|
||||
' auto: false',
|
||||
])
|
||||
|
||||
@@ -74,12 +72,11 @@ describe('real Loader composition', () => {
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(loaded.tokenMeter.contextWindow).toBe(4096)
|
||||
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
|
||||
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
expect((loaded.compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 512,
|
||||
retainRatio: 0.125,
|
||||
auto: false,
|
||||
})
|
||||
})
|
||||
@@ -87,8 +84,8 @@ describe('real Loader composition', () => {
|
||||
it('rejects stale token-meter config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(TokenMeterService, {
|
||||
models: { legacy: { contextWindow: 4096 } },
|
||||
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/)
|
||||
contextWindow: 4096,
|
||||
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/)
|
||||
})
|
||||
|
||||
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||
@@ -99,4 +96,33 @@ describe('real Loader composition', () => {
|
||||
models: { legacy: { thresholdRatio: 0.5 } },
|
||||
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
|
||||
})
|
||||
|
||||
it('rejects a capacity-independent merged ratio conflict during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
retainRatio: 0.2,
|
||||
modelPolicies: [{
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
thresholdRatio: 0.1,
|
||||
}],
|
||||
})).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/)
|
||||
})
|
||||
|
||||
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
modelPolicies: [{
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
summarizationModel: '',
|
||||
}],
|
||||
})).rejects.toThrow(/modelPolicies\[0\].*must be set together/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
@@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t
|
||||
|
||||
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
|
||||
|
||||
### Transcript supplied to a compaction consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Why automatic policy is asking a backend to consider compaction. */
|
||||
|
||||
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))
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Pure shared transcript projection for summarization and recall, so both
|
||||
* render the same log span byte-for-byte under replay.
|
||||
* @module @deepseek-ai/dsh-compact/render
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Render text directly, reasoning as a tagged span, and every other block as a
|
||||
* type-tagged placeholder. Tool results recurse into nested content; empty
|
||||
* blocks contribute nothing and rendered blocks join with newlines.
|
||||
*
|
||||
* @param blocks - the content blocks to render.
|
||||
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
|
||||
*/
|
||||
export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) parts.push(block.text)
|
||||
break
|
||||
case 'reasoning':
|
||||
if (block.text) parts.push(`[reasoning: ${block.text}]`)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
|
||||
break
|
||||
case 'tool-result': {
|
||||
const inner = renderContentBlocks(block.content)
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the reader rather than dropped.
|
||||
default:
|
||||
parts.push(`[${(block as ContentBlock).type}]`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message-producing events as a role-labeled transcript. `seqs` are
|
||||
* walked in caller-supplied surface order, which may differ from numeric log
|
||||
* order after replacement; non-surface and unknown merged events are skipped.
|
||||
*
|
||||
* @param events - the session log the seqs index into (`session.events`).
|
||||
* @param seqs - the surface-node seqs to render, in surface order.
|
||||
* @returns the transcript, entries joined by blank lines; empty string when nothing renders.
|
||||
*/
|
||||
export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const seq of seqs) {
|
||||
const event = events[seq]
|
||||
if (!event) continue
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`User: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`Assistant: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
const label = event.data.isError ? 'Tool error' : 'Tool result'
|
||||
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Context: ${text}]`)
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Steering: ${text}]`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
function session(): Session {
|
||||
return new Session(SessionId('render-spec'))
|
||||
}
|
||||
|
||||
describe('renderContentBlocks', () => {
|
||||
it('renders text blocks verbatim and skips empty ones', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: 'world' },
|
||||
])).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
it('wraps reasoning, skipping empty reasoning', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'reasoning', text: 'think' },
|
||||
{ type: 'reasoning', text: '' },
|
||||
])).toBe('[reasoning: think]')
|
||||
})
|
||||
|
||||
it('renders tool-call as a name(args) placeholder', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
|
||||
])).toBe('[tool-call: read({"filePath":"a"})]')
|
||||
})
|
||||
|
||||
it('renders tool-result with nested content, and bare when empty', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('c2'), content: [] },
|
||||
])).toBe('[tool-result: ok]\n[tool-result]')
|
||||
})
|
||||
|
||||
it('renders an unknown (merge-extended) block type as a bare type tag', () => {
|
||||
const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
|
||||
expect(renderContentBlocks([unknown])).toBe('[image]')
|
||||
})
|
||||
|
||||
it('returns the empty string for no blocks', () => {
|
||||
expect(renderContentBlocks([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTranscript', () => {
|
||||
it('renders each surface event type with its label, in the seq order given', () => {
|
||||
const s = session()
|
||||
const user = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'fix the bug' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'looking' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'exit 0' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const context = s.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const steering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: 'stop that' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
|
||||
'User: fix the bug',
|
||||
'Assistant: looking',
|
||||
'Tool result (call c1): exit 0',
|
||||
'[Context: file changed]',
|
||||
'[Steering: stop that]',
|
||||
].join('\n\n'))
|
||||
})
|
||||
|
||||
it('labels an error tool result "Tool error"', () => {
|
||||
const s = session()
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c9'),
|
||||
content: [{ type: 'text', text: 'boom' }],
|
||||
isError: true,
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
|
||||
})
|
||||
|
||||
it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
|
||||
const s = session()
|
||||
const first = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const second = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'second' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
|
||||
})
|
||||
|
||||
it('skips events that render to nothing, non-message events, and seqs with no event', () => {
|
||||
const s = session()
|
||||
const empty = s.append('user/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyResult = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c3'),
|
||||
content: [{ type: 'text', text: '' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyContext = s.append('context/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptySteering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
// A log-only (non-surface) event type: contributes nothing to a transcript.
|
||||
const lock = s.append('compact/start', { turn: 0 })
|
||||
expect(renderTranscript(s.events, [
|
||||
empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
|
||||
])).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -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' } })
|
||||
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[] {
|
||||
@@ -368,7 +367,7 @@ describe('real agent-loop request history', () => {
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
laterSawReading = contextTexts(subject.session).length === 1
|
||||
if (mode === 'throws') throw new Error('later pre-step failure')
|
||||
subject.cancel('later pre-step cancellation')
|
||||
subject.cancel({ kind: 'user' })
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { lstat, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
@@ -469,5 +469,5 @@ export async function readScopeInstruction(
|
||||
}
|
||||
|
||||
function userGlobalDisplayPath(dshHome: string): string {
|
||||
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
||||
return `${dshHomeDisplay(dshHome)}/AGENTS.md`
|
||||
}
|
||||
|
||||
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 */
|
||||
@@ -500,7 +500,7 @@ export async function dynamicInstructionContext(
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -40,6 +46,8 @@ import {
|
||||
} from '../src/state.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
async function tempRepo(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
|
||||
}
|
||||
@@ -225,14 +233,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 +820,8 @@ 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({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -831,6 +857,7 @@ describe('workspace context request injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const exec = stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-blocked-post-execute'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -842,7 +869,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 +883,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')
|
||||
@@ -976,6 +1003,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
await write(join(root, 'AGENTS.md'), 'new root rule with more detail')
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1004,6 +1032,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
await rm(join(root, 'AGENTS.md'))
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1029,6 +1058,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1107,6 +1137,25 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the direct provider API usable without an operation signal', async () => {
|
||||
const root = '/virtual/no-signal-repo'
|
||||
const home = '/virtual/no-signal-home'
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'optional capability signal' })
|
||||
|
||||
const rendered = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }, fs)
|
||||
|
||||
expect(rendered?.text).toContain('optional capability signal')
|
||||
expect(fs.signals).toEqual([])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a provider-sized instruction file before reading content', async () => {
|
||||
const root = join(await tempRepo(), 'virtual-repo')
|
||||
const home = join(await tempRepo(), 'virtual-home')
|
||||
@@ -1168,8 +1217,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),
|
||||
)
|
||||
|
||||
@@ -1596,7 +1646,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
description: 'Abort the current test step.',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'aborted' }]
|
||||
},
|
||||
}))
|
||||
@@ -1659,7 +1709,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 }))
|
||||
@@ -1686,6 +1736,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -1745,6 +1796,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-configured-nested-candidate'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -1773,12 +1825,14 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested-1'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
agent,
|
||||
})
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested-2'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -1811,10 +1865,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1846,14 +1902,17 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') })
|
||||
const afterVersionChange = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
const afterRefresh = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1884,9 +1943,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
|
||||
})
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
|
||||
})
|
||||
|
||||
@@ -1912,11 +1973,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
|
||||
const changed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1952,15 +2015,18 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
await rm(join(root, 'pkg/AGENTS.md'))
|
||||
const changed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, changed)
|
||||
const unchanged = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -1991,11 +2057,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
await rm(join(root, 'pkg/AGENTS.md'))
|
||||
const removed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -2029,17 +2097,20 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
await rm(join(root, 'pkg/AGENTS.md'))
|
||||
const removed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, removed)
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
|
||||
|
||||
const restored = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -2071,11 +2142,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
|
||||
const duringFailure = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
@@ -2099,6 +2172,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-resume'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2111,6 +2185,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
|
||||
const afterResume = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-resume'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2136,6 +2211,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const original = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
|
||||
})
|
||||
appendAdditionalContexts(original, first)
|
||||
@@ -2166,6 +2242,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-compact'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2173,6 +2250,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
const contextSeq = appendAdditionalContexts(agent, first)!
|
||||
const visibleBeforeCompact = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-while-visible'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2188,6 +2266,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const afterCompact = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-compact'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2217,6 +2296,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-package'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -2225,6 +2305,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
appendAdditionalContexts(agent, first)
|
||||
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-subtree'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/sub/file.txt' },
|
||||
@@ -2252,6 +2333,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-subtree-omitting-parent'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/sub/file.txt' },
|
||||
@@ -2260,6 +2342,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
appendAdditionalContexts(agent, first)
|
||||
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-parent-after-omit'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/other.txt' },
|
||||
@@ -2321,6 +2404,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-spoofed-state'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2347,12 +2431,14 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const rootResult = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-root-file'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'root.txt' },
|
||||
agent,
|
||||
})
|
||||
const absoluteResult = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-absolute-nested-file'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join(root, 'pkg/deep/file.txt') },
|
||||
@@ -2385,12 +2471,14 @@ describe('dynamic nested workspace context injection', () => {
|
||||
isError: false,
|
||||
}
|
||||
|
||||
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const failedStat = await postExecute(ctx, stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
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({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
|
||||
@@ -2416,6 +2504,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-with-unreadable-nested-instruction'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2450,6 +2539,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-with-downstream'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2494,6 +2584,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-blocked-downstream'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2534,6 +2625,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const blocked = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('outer-block-first'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2541,6 +2633,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
shouldBlock = false
|
||||
const accepted = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('outer-block-retry'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2581,7 +2674,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
...exec.agent === undefined ? {} : { agent: exec.agent },
|
||||
parent: exec.token,
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
signal: exec.signal,
|
||||
})
|
||||
for (const context of nested.additionalContexts ?? []) exec.deferContext(context)
|
||||
return nested.content
|
||||
@@ -2598,10 +2691,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const blocked = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent,
|
||||
})
|
||||
shouldBlock = false
|
||||
const accepted = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent,
|
||||
})
|
||||
|
||||
@@ -2624,20 +2719,24 @@ 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({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
|
||||
}), plainResult)
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
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({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
|
||||
ctx.emit('tools/result', {
|
||||
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
emitToolResult(ctx, {
|
||||
...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
token: parent,
|
||||
}, plainResult)
|
||||
|
||||
@@ -2672,7 +2771,8 @@ 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({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
|
||||
name: item.name,
|
||||
arguments: item.arguments,
|
||||
@@ -2697,6 +2797,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-with-disabled-budget'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2721,6 +2822,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-missing'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/missing.txt' },
|
||||
@@ -2747,6 +2849,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-dispose'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
|
||||
@@ -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.',
|
||||
@@ -330,6 +340,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async listModels(provider: string): Promise<LlmModelInfo[]>',
|
||||
jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>',
|
||||
jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
|
||||
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
|
||||
@@ -639,7 +653,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
||||
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
|
||||
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -703,9 +717,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/cancel-requested',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, reason: string): void',
|
||||
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.',
|
||||
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void',
|
||||
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted.',
|
||||
},
|
||||
{
|
||||
name: 'agent/created',
|
||||
@@ -745,8 +759,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/prompt-submit',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
|
||||
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
|
||||
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. The signal controls only\n * this turn; listeners may cooperate with it but must not retain it to\n * control another turn.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
|
||||
},
|
||||
{
|
||||
@@ -759,8 +773,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/request',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
|
||||
jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
|
||||
jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Replace the frozen call configuration.',
|
||||
},
|
||||
{
|
||||
@@ -774,7 +788,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/session-prefix',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
|
||||
jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - aborts composition when the step is torn down.\n * @mode waterfall\n */',
|
||||
jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */',
|
||||
summary: 'Compose request-only messages placed before derived history.',
|
||||
},
|
||||
{
|
||||
@@ -794,22 +808,22 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/step-result',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/step-result\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>',
|
||||
jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
signature: '\'agent/step-result\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>',
|
||||
jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).',
|
||||
},
|
||||
{
|
||||
name: 'agent/turn-continuation',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/turn-continuation\'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
|
||||
jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
signature: '\'agent/turn-continuation\'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
|
||||
jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Override whether the turn continues.',
|
||||
},
|
||||
{
|
||||
name: 'agent/turn-stop',
|
||||
mode: 'serial',
|
||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined',
|
||||
jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
|
||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
|
||||
jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
|
||||
summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.',
|
||||
},
|
||||
{
|
||||
@@ -921,7 +935,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'system-prompt/assemble',
|
||||
mode: 'waterfall',
|
||||
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
|
||||
jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
|
||||
jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
|
||||
summary: 'Expert waterfall over the assembled sections, tools, and variables.',
|
||||
},
|
||||
{
|
||||
@@ -941,22 +955,22 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'tools/execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
|
||||
jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */',
|
||||
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
|
||||
jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable. The registry re-fuses the original caller\n * signal before the body, so replacement cannot detach caller cancellation;\n * wrappers must still restore their signal and reach quiescence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */',
|
||||
summary: 'Around-dispatch waterfall for timeout, retry, or metrics.',
|
||||
},
|
||||
{
|
||||
name: 'tools/post-execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
|
||||
jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */',
|
||||
jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors. Async\n * listeners must observe `exec.signal`; after they settle, caller\n * cancellation replaces only a successful accepted outcome with the code\n * selected by whether the tool body was invoked.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */',
|
||||
summary: 'Accept, replace, enrich, or block a normalized dispatch result.',
|
||||
},
|
||||
{
|
||||
name: 'tools/pre-execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
|
||||
jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */',
|
||||
jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */',
|
||||
summary: 'Allow, deny, or ask before dispatch.',
|
||||
},
|
||||
{
|
||||
@@ -1014,7 +1028,11 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
{
|
||||
name: 'Agent',
|
||||
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise<void>;\n}',
|
||||
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentCancelCause',
|
||||
declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'AgentFactory',
|
||||
@@ -1066,7 +1084,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AssembleContext',
|
||||
declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n}',
|
||||
declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssembledSection',
|
||||
@@ -1336,6 +1354,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};',
|
||||
@@ -1348,6 +1374,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'LlmFailure',
|
||||
declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmModelContext',
|
||||
declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmModelInfo',
|
||||
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
|
||||
@@ -1690,7 +1720,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionInput',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionMode',
|
||||
@@ -1742,7 +1772,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'TurnEndReasonMap',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnTrigger',
|
||||
|
||||
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 */
|
||||
@@ -6,6 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer +
|
||||
* tool-cordis tree (only the model is absent — the code strings below stand in
|
||||
@@ -27,7 +29,7 @@ let callCounter = 0
|
||||
|
||||
/** Execute a registered tool through the real registry pipeline. */
|
||||
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
}
|
||||
|
||||
/** Concatenated text blocks of one tool result. */
|
||||
|
||||
@@ -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
|
||||
@@ -56,7 +60,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
|
||||
|
||||
@@ -105,7 +109,7 @@ Ordinary history growth is append-only and preserves reusable entries. A surface
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
|
||||
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code `ABORTED_BEFORE_DISPATCH` and result text `Error: tool call aborted before dispatch`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
@@ -95,7 +96,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
* Owns the inbox (queued + steering FIFOs), the per-step AbortController, and
|
||||
* Owns the inbox (queued + steering FIFOs), turn cancellation, and
|
||||
* the loop driver. Everything observable happens through session events and
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
@@ -120,21 +121,14 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/** Active turn owner from pre-running publication through durability settlement. */
|
||||
private turnCancellation: TurnCancellation | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/** Whether registry publication began and status disposal is externally visible. */
|
||||
private published = false
|
||||
/**
|
||||
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
|
||||
* driver loop (via the LoopHandle) at every point a turn could start or
|
||||
* continue. Armed ONLY when there is something to cancel (a running turn, an
|
||||
* in-flight step, or queued/steering work), so an idle no-op cancel cannot
|
||||
* leave it set to wrongly drop a later prompt.
|
||||
*/
|
||||
private cancelRequested = false
|
||||
/** Pending cancellation reason, preserved even outside an active step signal. */
|
||||
private cancelReason = 'cancelled'
|
||||
/** Cause-less marker for queued work cancelled before the driver installs a turn owner. */
|
||||
private preRunCancelled = false
|
||||
private disposed: Promise<void>
|
||||
private resolveDisposed!: () => void
|
||||
/** Resolves when the driver loop has fully exited (tests/disposal). */
|
||||
@@ -330,29 +324,21 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
const resolvedReason = reason ?? 'cancelled'
|
||||
// Arm only for current work; an idle marker would cancel the next prompt.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
this.cancelRequested = true
|
||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||
// continuation). The mid-step path reads it from abort.signal.reason
|
||||
// below; the marker path reads it via the LoopHandle's cancelReason().
|
||||
this.cancelReason = resolvedReason
|
||||
cancel(cause?: AgentCancelCause): void {
|
||||
const resolvedCause = cause ?? { kind: 'user' }
|
||||
const cancellation = this.turnCancellation
|
||||
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
if (cancellation !== undefined || preRun) {
|
||||
if (preRun) this.preRunCancelled = true
|
||||
// Coordination consumers must update their own state before this call
|
||||
// clears the inbox or aborts the step. Notification failures are
|
||||
// clears the inbox or aborts the turn. Notification failures are
|
||||
// contained by the fused dispatcher and cannot veto cancellation.
|
||||
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason)
|
||||
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
|
||||
}
|
||||
// Drop all pending queued + steering work (un-started prompts never run; the
|
||||
// cancelled turn's steering is not re-enqueued). Cleared directly even when
|
||||
// the loop is parked in waitForQueued — there is no turn to stop and nothing
|
||||
// left for the parked loop to run, so no wake is needed.
|
||||
// Clear work already present before abort observers run. A replacement
|
||||
// synchronously enqueued by an observer belongs to the next turn.
|
||||
this.#inbox.clear()
|
||||
// Interrupt an in-flight step immediately (the running turn observes the
|
||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||
// running (pre-step, continuation).
|
||||
this.currentAbort?.abort(resolvedReason)
|
||||
cancellation?.request(resolvedCause)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,14 +380,21 @@ export class ReactLoopAgent implements Agent {
|
||||
inbox: this.#inbox,
|
||||
maxParallelToolCalls: this.maxParallelToolCalls,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
installTurnCancellation: () => {
|
||||
const cancellation = new TurnCancellation()
|
||||
this.turnCancellation = cancellation
|
||||
return cancellation
|
||||
},
|
||||
clearTurnCancellation: (cancellation) => {
|
||||
/* v8 ignore else -- the driver clears only the exact owner returned by its latest install. */
|
||||
if (this.turnCancellation === cancellation) this.turnCancellation = undefined
|
||||
},
|
||||
disposed: this.disposed,
|
||||
isDisposed: () => this._status === 'disposed',
|
||||
isCancelled: () => this.cancelRequested,
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
isPreRunCancelled: () => this.preRunCancelled,
|
||||
clearPreRunCancel: () => { this.preRunCancelled = false },
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-start cancellation settles queued-work waiters before publishing idle.
|
||||
// Pre-run cancellation settles queued-work waiters before publishing idle.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
}))
|
||||
}
|
||||
@@ -419,7 +412,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
this.turnCancellation?.request(DISPOSED_INTERRUPT_REASON)
|
||||
// An unpublished rollback has no public status lifecycle to announce.
|
||||
// Once publication begins, disposed is part of the agent/status contract.
|
||||
if (this.published) {
|
||||
|
||||
31
packages/core/agent-loop/src/cancellation.ts
Normal file
31
packages/core/agent-loop/src/cancellation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */
|
||||
|
||||
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
|
||||
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
|
||||
|
||||
/**
|
||||
* Owns the single controller shared by every asynchronous boundary of one turn.
|
||||
* The first request wins because a later caller must not rewrite the cause
|
||||
* observed by earlier listeners.
|
||||
*/
|
||||
export class TurnCancellation {
|
||||
readonly #controller = new AbortController()
|
||||
|
||||
/** The explicit signal passed through this turn's execution boundaries. */
|
||||
get signal(): AbortSignal {
|
||||
return this.#controller.signal
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the turn once.
|
||||
* @param reason - a typed caller cause or lifecycle disposal marker.
|
||||
* @returns whether this request established the signal reason.
|
||||
*/
|
||||
request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean {
|
||||
if (this.signal.aborted) return false
|
||||
this.#controller.abort(Object.freeze({ kind: reason.kind }))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class Inbox {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
|
||||
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -9,17 +9,19 @@ import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
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'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import type { TurnCancellation } from './cancellation.ts'
|
||||
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
function toError(error: unknown): RequestError {
|
||||
@@ -88,6 +90,32 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal control-flow sentinel; durable classification comes only from the turn signal. */
|
||||
const TURN_INTERRUPTED = new Error('turn interrupted')
|
||||
|
||||
/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
|
||||
function interruptionCheckpoint(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw TURN_INTERRUPTED
|
||||
}
|
||||
|
||||
/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */
|
||||
function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined {
|
||||
if (handle.isDisposed()) return { kind: 'disposed' }
|
||||
const reason = agentInterruptReasonOf(signal)
|
||||
if (reason === undefined) return undefined
|
||||
switch (reason.kind) {
|
||||
case 'user':
|
||||
case 'parent':
|
||||
return { kind: 'aborted' }
|
||||
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
|
||||
case 'disposed':
|
||||
return { kind: 'disposed' }
|
||||
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
|
||||
default:
|
||||
return assertNever(reason, 'AgentInterruptReason')
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable agent controls supplied to the loop driver. */
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
@@ -95,16 +123,17 @@ export interface LoopHandle {
|
||||
/** Maximum parallel-safe calls allowed in one step. */
|
||||
readonly maxParallelToolCalls: number
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Install a fresh active-turn owner before the running notification. */
|
||||
installTurnCancellation(): TurnCancellation
|
||||
/** Clear only the exact owner whose turn reached its terminal event boundary. */
|
||||
clearTurnCancellation(cancellation: TurnCancellation): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
disposed: Promise<void>
|
||||
isDisposed(): boolean
|
||||
/** Whether cancellation is pending for the current loop iteration. */
|
||||
isCancelled(): boolean
|
||||
/** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */
|
||||
cancelReason(): string
|
||||
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
||||
clearCancel(): void
|
||||
/** Whether queued work was cancelled before an active turn owner existed. */
|
||||
isPreRunCancelled(): boolean
|
||||
/** Clear the cause-less pre-run marker without affecting replacement work. */
|
||||
clearPreRunCancel(): void
|
||||
/** Settle idle waiters before pre-running cancellation publishes idle. */
|
||||
settleIdle(): void
|
||||
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
|
||||
@@ -119,7 +148,7 @@ export interface LoopHandle {
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
* through.
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
|
||||
* @throws when no initiating Agent is active.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
@@ -134,8 +163,8 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
while (!handle.isDisposed()) {
|
||||
// An idle listener can enqueue and cancel replacement work before the next
|
||||
// wait is installed. Consume that empty marker before parking the driver.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
@@ -148,8 +177,8 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs before the eventual idle transition.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
@@ -159,24 +188,29 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
let cancellation = handle.installTurnCancellation()
|
||||
handle.setStatus('running')
|
||||
if (handle.isDisposed()) break
|
||||
if (handle.isDisposed()) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
break
|
||||
}
|
||||
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (cancellation.signal.aborted) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
cancellation = handle.installTurnCancellation()
|
||||
}
|
||||
|
||||
// Idle injection can add a turn, so derive the next number from the log.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
@@ -184,11 +218,10 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
try {
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
} finally {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
}
|
||||
|
||||
// Reset per iteration, including when a prompt arrives during the flush window.
|
||||
handle.clearCancel()
|
||||
|
||||
// Late steering becomes queued input unless terminal policy stopped the turn.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
@@ -200,9 +233,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
cancellation: TurnCancellation,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const { signal } = cancellation
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
@@ -246,8 +281,11 @@ async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-commit validation failure escapes rather than masquerading as a committed boundary.
|
||||
// Retire cancellation authority before publishing the terminal event. The
|
||||
// following durability flush is quiescent turn work, but no longer part of
|
||||
// the cancellable turn lifetime.
|
||||
const closeTurn = (): void => {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
@@ -256,15 +294,17 @@ async function runTurn(
|
||||
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
interruptionCheckpoint(signal)
|
||||
// The claimed message runs the `agent/prompt-submit` waterfall before it
|
||||
// becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
const promptDecision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
'agent/prompt-submit', message.content, message.source, signal,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (promptDecision.kind === 'block') {
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
||||
reason = { kind: 'rejected', reason: promptDecision.reason }
|
||||
@@ -292,53 +332,28 @@ async function runTurn(
|
||||
// the request.
|
||||
drainSteering()
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
// async listener whose effect fires before we block — always has an armed
|
||||
// abort to cancel against. isDisposed below covers disposal, which does
|
||||
// NOT set the cancel marker. Cleared on every exit path below.
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Assemble once before pre-step so listener work and the request share one prompt value.
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
|
||||
interruptionCheckpoint(signal)
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Cancellation or disposal during assembly ends the turn before any step opens.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
// Compose the request-only prefix once per loop instance before the first
|
||||
// request boundary. It precedes all derived history and is recorded only
|
||||
// in the request header, not as session history.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
'agent/session-prefix', emptyPrefix, abort.signal,
|
||||
'agent/session-prefix', emptyPrefix, signal,
|
||||
() => Promise.resolve(emptyPrefix),
|
||||
)
|
||||
|
||||
// Never cache an interrupted composition; the next turn recomposes it.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
interruptionCheckpoint(signal)
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Await surface mutations outside the step before snapshotting history.
|
||||
await events.serial('agent/pre-step', turn, step, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
await events.serial('agent/pre-step', turn, step, signal)
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Snapshot the exact log prefix before step/start: the reconstruction
|
||||
// boundary. Appends after this synchronous snapshot join the next request.
|
||||
@@ -350,16 +365,8 @@ async function runTurn(
|
||||
// are contained inside Session.append().
|
||||
stepOpen = true
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `session/event`
|
||||
// step/start listener can cancel after the step is already open. Check
|
||||
// AFTER the step/start append and before `runStep`: drop the step, end the
|
||||
// turn accordingly. closeStep balances the already-appended step/start.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
// A synchronous step/start observer can cancel after the step opened.
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
let stepOutcome:
|
||||
| { hadToolCalls: boolean; finish: FinishReason }
|
||||
@@ -367,7 +374,7 @@ async function runTurn(
|
||||
| { error: RequestError }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TerminalModelRequestFailure) {
|
||||
stepOutcome = { requestError: error.requestError, failure: error.failure }
|
||||
@@ -380,11 +387,9 @@ async function runTurn(
|
||||
// Recovery observes a balanced failed step and the original provider
|
||||
// error while the failed step's signal remains the active owner.
|
||||
closeStep()
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted !== undefined) {
|
||||
reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
@@ -393,7 +398,7 @@ async function runTurn(
|
||||
try {
|
||||
recoveryDecision = await events.waterfall(
|
||||
'agent/request-error', turn, step, stepOutcome.requestError,
|
||||
stepOutcome.failure, requestFailureHistory, abort.signal,
|
||||
stepOutcome.failure, requestFailureHistory, signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (recoveryError: unknown) {
|
||||
@@ -401,15 +406,11 @@ async function runTurn(
|
||||
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
handle.setAbort(undefined)
|
||||
|
||||
// Cancellation and disposal always win over either a recovery decision
|
||||
// or a recovery-listener failure.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (recoveryInterrupted !== undefined) {
|
||||
reason = recoveryInterrupted
|
||||
break
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
@@ -431,17 +432,10 @@ async function runTurn(
|
||||
// runLoop re-enqueues it as a queued message, so an abort-then-steer
|
||||
// starts a fresh turn instead of being silently consumed.
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
const { error } = stepOutcome
|
||||
/* v8 ignore next -- narrow race: disposal while non-request step work throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
} else {
|
||||
failTurn(error)
|
||||
}
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
@@ -455,48 +449,40 @@ async function runTurn(
|
||||
const steered = drainSteering()
|
||||
|
||||
try {
|
||||
await events.serial('agent/post-step', turn, step, abort.signal)
|
||||
await events.serial('agent/post-step', turn, step, signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
/* v8 ignore next -- narrow race: disposal while a post-step listener throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- signal.reason always set by cancellation or disposal. */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
} else {
|
||||
failTurn(stepOutcome.error)
|
||||
}
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(stepOutcome.error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (postStepInterrupted !== undefined) {
|
||||
reason = postStepInterrupted
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
break
|
||||
}
|
||||
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
try {
|
||||
decision = await events.waterfall(
|
||||
'agent/turn-continuation', turn, defaultDecision,
|
||||
'agent/turn-continuation', turn, defaultDecision, signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
} catch (error: unknown) {
|
||||
// A broken continuation plugin ends the turn, not the loop.
|
||||
failTurn(toError(error))
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
@@ -512,12 +498,15 @@ async function runTurn(
|
||||
// Terminal policy is monotonic and runs after ordinary continuation folding.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.serial('agent/turn-stop', turn)
|
||||
const stop = await events.serial('agent/turn-stop', turn, signal)
|
||||
interruptionCheckpoint(signal)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
failTurn(toError(error))
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
@@ -527,17 +516,7 @@ async function runTurn(
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
// The marker catches cancellation after the step controller was cleared.
|
||||
if (handle.isCancelled()) {
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
if (!shouldContinue || handle.isDisposed()) {
|
||||
/* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
|
||||
if (handle.isDisposed()) reason = { kind: 'disposed' }
|
||||
break
|
||||
}
|
||||
if (!shouldContinue) break
|
||||
}
|
||||
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
@@ -547,12 +526,9 @@ async function runTurn(
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
// Preserve an established disposal reason; otherwise report the failure.
|
||||
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
reason = { kind: 'disposed' }
|
||||
} else {
|
||||
failTurn(toError(error))
|
||||
}
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
closeTurn()
|
||||
}
|
||||
|
||||
@@ -601,7 +577,10 @@ async function runStep(
|
||||
: { provider: options.provider ?? '', model: options.model ?? '' }))
|
||||
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
|
||||
const config = await events.waterfall(
|
||||
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (!config.provider || !config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
}
|
||||
@@ -619,7 +598,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 +609,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()
|
||||
@@ -638,8 +617,7 @@ async function runStep(
|
||||
const stream = ctx.llm.stream(request)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
interruptionCheckpoint(signal)
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
@@ -649,6 +627,7 @@ async function runStep(
|
||||
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
|
||||
throw error
|
||||
}
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Normalize failure finish chunks into the same path as thrown stream errors.
|
||||
const stepError = finishError(assembler.finish)
|
||||
@@ -679,9 +658,11 @@ async function runStep(
|
||||
// A rejected result still records the successful provider call without retaining rejected output.
|
||||
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
|
||||
try {
|
||||
return await events.waterfall(
|
||||
'agent/step-result', turn, step, message, () => Promise.resolve(message),
|
||||
const processed = await events.waterfall(
|
||||
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
return processed
|
||||
} catch (error: unknown) {
|
||||
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
|
||||
throw error
|
||||
|
||||
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
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type { Context } from 'cordis'
|
||||
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
interface PlannedCall {
|
||||
@@ -217,9 +217,9 @@ async function runGroup(
|
||||
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
|
||||
const callSeq = appendToolCall(session, turn, step, block)
|
||||
appendToolResult(session, turn, step, block, {
|
||||
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
}, callSeq)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
agentsFiber: Fiber
|
||||
@@ -142,6 +144,80 @@ describe('AgentLoop initiator scope', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps initiator identity minimal while one explicit signal spans each turn seam', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('observe-call', 'observe', {}),
|
||||
textResponse('first done'),
|
||||
textResponse('second done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
|
||||
let signals: AbortSignal[] = []
|
||||
const capture = (signal: AbortSignal | undefined): void => {
|
||||
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
|
||||
expect(ctx.agents.requireInitiator()).toBe(agent)
|
||||
signals.push(signal)
|
||||
}
|
||||
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (context.agent === agent) capture(context.signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/pre-step', (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/turn-stop', (subject, _turn, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'observe',
|
||||
description: 'observe explicit turn state',
|
||||
parameters: {},
|
||||
execute: async (_args, exec) => {
|
||||
capture(exec.signal)
|
||||
return [{ type: 'text', text: 'observed' }]
|
||||
},
|
||||
}))
|
||||
|
||||
const firstIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await firstIdle
|
||||
const firstSignal = signals[0]
|
||||
expect(firstSignal).toBeDefined()
|
||||
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
|
||||
|
||||
signals = []
|
||||
const secondIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await secondIdle
|
||||
const secondSignal = signals[0]
|
||||
expect(secondSignal).toBeDefined()
|
||||
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
|
||||
expect(secondSignal).not.toBe(firstSignal)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('spawn', 'spawn-child', {}),
|
||||
@@ -239,6 +315,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
}))
|
||||
|
||||
const direct = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('direct'),
|
||||
name: 'agentless-probe',
|
||||
arguments: {},
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('Agent', () => {
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await waitForStatus(ctx, agent, 'running')
|
||||
agent.cancel('done')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
|
||||
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start —
|
||||
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
|
||||
* and leaves the queue intact. The suite covers every landing window plus marker
|
||||
* reset and `whenIdle()` quiescence.
|
||||
* clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
|
||||
* driver without leaking cancellation into a replacement prompt. The suite covers every landing
|
||||
* window plus signal reset and `whenIdle()` quiescence.
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
@@ -12,7 +11,7 @@ import { Context } from 'cordis'
|
||||
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
@@ -61,22 +60,22 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
|
||||
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, reason) => {
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject !== agent) return
|
||||
seen.push(`first:${reason}`)
|
||||
seen.push(`first:${cause.kind}`)
|
||||
subject.send([{ type: 'text', text: 'queued by cancel observer' }])
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, reason) => {
|
||||
if (subject === agent) seen.push(`second:${reason}`)
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject === agent) seen.push(`second:${cause.kind}`)
|
||||
})
|
||||
|
||||
send(agent, 'drop me')
|
||||
agent.cancel()
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
agent.cancel('idle no-op')
|
||||
agent.cancel({ kind: 'parent' })
|
||||
|
||||
expect(seen).toEqual(['first:cancelled', 'second:cancelled'])
|
||||
expect(seen).toEqual(['first:user', 'second:user'])
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
|
||||
@@ -89,7 +88,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
// The loop is parked at the idle wait with nothing queued. A cancel here must
|
||||
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
|
||||
agent.cancel('nothing to cancel')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
send(agent, 'real prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -108,7 +107,7 @@ describe('Agent.cancel()', () => {
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me first')
|
||||
send(agent, 'drop me second')
|
||||
agent.cancel('pre-step')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
// Give the loop a chance to wake and process the cancel.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -157,7 +156,7 @@ describe('Agent.cancel()', () => {
|
||||
// drops the turn before it runs; the skip path must settle it directly.
|
||||
send(agent, 'q')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel('pre-step')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
// Must resolve (not hang). A timeout makes the failure a clear test failure.
|
||||
await Promise.race([
|
||||
@@ -186,7 +185,7 @@ describe('Agent.cancel()', () => {
|
||||
// before its resolved waitForQueued continuation checks cancellation.
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => {
|
||||
agent.cancel('between turns')
|
||||
agent.cancel({ kind: 'user' })
|
||||
cancelled.resolve(undefined)
|
||||
})
|
||||
})
|
||||
@@ -236,7 +235,7 @@ describe('Agent.cancel()', () => {
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject !== agent || error.message !== 'first flush failed') return
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => { agent.cancel('between turns') })
|
||||
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,7 +276,7 @@ describe('Agent.cancel()', () => {
|
||||
requests: adapter.requests.length,
|
||||
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
|
||||
}))
|
||||
agent.cancel('idle listener')
|
||||
agent.cancel({ kind: 'user' })
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
@@ -307,7 +306,7 @@ describe('Agent.cancel()', () => {
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
|
||||
send(agent, 'cancelled replacement')
|
||||
agent.cancel('idle listener')
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'surviving replacement')
|
||||
replacementIdle = agent.whenIdle()
|
||||
replacementRegistered.resolve(undefined)
|
||||
@@ -334,16 +333,16 @@ describe('Agent.cancel()', () => {
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
send(agent, 'queued tail')
|
||||
agent.cancel('mid-step')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
expect(userTexts(agent)).toEqual(['go'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||
it('cancel() with no cause defaults to user when aborting an active turn', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -353,10 +352,10 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel() // no reason → default 'cancelled'
|
||||
agent.cancel()
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
|
||||
@@ -378,7 +377,7 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/message') {
|
||||
agent.cancel('cancelled after assistant message')
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -390,14 +389,14 @@ describe('Agent.cancel()', () => {
|
||||
dispose()
|
||||
|
||||
expect(executions).toBe(0)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
const call = agent.session.events.find(event => event.type === 'tool/call')
|
||||
const result = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
|
||||
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
|
||||
callId: 'c1',
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
|
||||
send(agent, 'continue safely')
|
||||
@@ -407,7 +406,7 @@ describe('Agent.cancel()', () => {
|
||||
.find(block => block.type === 'tool-result')
|
||||
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'aborted', reason: 'cancelled after assistant message' },
|
||||
{ kind: 'aborted' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
@@ -420,7 +419,7 @@ describe('Agent.cancel()', () => {
|
||||
// First turn hangs; cancel it mid-step.
|
||||
send(agent, 'first')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel('cancel first')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The marker must have been reset after the cancelled turn — a fresh prompt
|
||||
@@ -445,7 +444,7 @@ describe('Agent.cancel()', () => {
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||
agent.cancel('from prefix composition')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -456,7 +455,7 @@ describe('Agent.cancel()', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
|
||||
@@ -508,7 +507,7 @@ describe('Agent.cancel()', () => {
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
compositions += 1
|
||||
if (compositions === 1) {
|
||||
agent.cancel('mid-composition')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return next()
|
||||
}
|
||||
return [opener, ...await next()]
|
||||
@@ -536,7 +535,7 @@ describe('Agent.cancel()', () => {
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
|
||||
if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -547,10 +546,10 @@ describe('Agent.cancel()', () => {
|
||||
dispose()
|
||||
|
||||
// No step streamed (the model never ran), and the turn ended aborted with
|
||||
// the CALLER's reason — the marker carries `cancel(reason)` through even
|
||||
// the caller's cause — the marker carries `cancel(cause)` through even
|
||||
// though no AbortController observed it in this window.
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
|
||||
@@ -565,7 +564,7 @@ describe('Agent.cancel()', () => {
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
|
||||
if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -575,10 +574,10 @@ describe('Agent.cancel()', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
// No step streamed, the turn ended aborted with the caller's reason, and the
|
||||
// No step streamed, the turn ended with the coarse aborted outcome, and the
|
||||
// log is balanced (the open step was closed by the cancel branch).
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
})
|
||||
@@ -636,11 +635,11 @@ describe('Agent.cancel()', () => {
|
||||
})
|
||||
|
||||
let continued = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
|
||||
if (subject === agent && !continued) {
|
||||
continued = true
|
||||
agent.cancel('from continuation')
|
||||
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
|
||||
agent.cancel({ kind: 'user' })
|
||||
return { action: 'continue' as const }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
@@ -649,10 +648,9 @@ describe('Agent.cancel()', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Only ONE step ran (the second was cancelled in the continuation window),
|
||||
// and the turn ended aborted with the CALLER's reason (carried by the
|
||||
// marker, since the finished step's AbortController was already cleared).
|
||||
// and the shared turn signal classified the durable outcome as aborted.
|
||||
expect(steps).toBe(1)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||
@@ -665,7 +663,7 @@ describe('Agent.cancel()', () => {
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'running') agent.cancel('from running listener')
|
||||
if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -688,7 +686,7 @@ describe('Agent.cancel()', () => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'running' || replaced) return
|
||||
replaced = true
|
||||
agent.cancel('drop A')
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'B')
|
||||
})
|
||||
|
||||
@@ -713,7 +711,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
send(agent, 'A') // queues A (status still idle, loop microtask pending)
|
||||
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
|
||||
agent.cancel('drop A') // arms marker, clears A
|
||||
agent.cancel({ kind: 'user' }) // arms marker, clears A
|
||||
send(agent, 'B') // B races in before the loop resumes
|
||||
|
||||
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
|
||||
@@ -736,7 +734,7 @@ describe('Agent.cancel()', () => {
|
||||
// Steer (joins the running turn's steering FIFO), then cancel: the steering
|
||||
// must be dropped, NOT re-enqueued as a new queued turn.
|
||||
agent.steer([{ type: 'text', text: 'steer text' }])
|
||||
agent.cancel('cancel with steering')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// After the cancelled turn settles, the agent is idle with NO follow-up turn
|
||||
@@ -752,4 +750,228 @@ describe('Agent.cancel()', () => {
|
||||
.flatMap(b => b.type === 'text' ? [b.text] : [])
|
||||
expect(flat).not.toContain('steer text')
|
||||
})
|
||||
|
||||
it('keeps replacement work queued synchronously by an abort observer', async () => {
|
||||
const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'original')
|
||||
await expect.poll(() => adapter.requests.length).toBe(1)
|
||||
const signal = adapter.requests[0]?.signal
|
||||
if (signal === undefined) throw new Error('model request omitted its turn signal')
|
||||
signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.cancel({ kind: 'user' })
|
||||
await Promise.race([
|
||||
idle,
|
||||
new Promise((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`replacement did not settle: ${JSON.stringify({
|
||||
status: agent.status,
|
||||
requests: adapter.requests.length,
|
||||
users: userTexts(agent),
|
||||
events: agent.session.events.map(event => event.type),
|
||||
})}`))
|
||||
}, 1000)
|
||||
}),
|
||||
])
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['original', 'replacement'])
|
||||
const reasons = agent.session.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
|
||||
})
|
||||
|
||||
it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
|
||||
const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' }
|
||||
|
||||
send(agent, 'go')
|
||||
await expect.poll(() => adapter.requests.length).toBe(1)
|
||||
agent.cancel(supplied)
|
||||
supplied.kind = 'user'
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const runtimeReason: unknown = adapter.requests[0]?.signal?.reason
|
||||
expect(runtimeReason).toEqual({ kind: 'parent' })
|
||||
expect(runtimeReason).not.toBe(supplied)
|
||||
expect(Object.isFrozen(runtimeReason)).toBe(true)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
|
||||
it('retires turn cancellation before terminal publication and a blocked durability flush', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' })
|
||||
const flushStarted = Promise.withResolvers<undefined>()
|
||||
const releaseFlush = Promise.withResolvers<undefined>()
|
||||
let abortedDuringTurnEnd: boolean | undefined
|
||||
let cancelNotifications = 0
|
||||
|
||||
ctx.on('agent/cancel-requested', (subject) => {
|
||||
if (subject === agent) cancelNotifications += 1
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'turn/end') return
|
||||
const signal = adapter.requests[0]?.signal
|
||||
if (signal === undefined) throw new Error('model request omitted its turn signal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
abortedDuringTurnEnd = signal.aborted
|
||||
})
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
})
|
||||
|
||||
send(agent, 'finish before persistence drains')
|
||||
await flushStarted.promise
|
||||
const signal = adapter.requests[0]?.signal
|
||||
if (signal === undefined) throw new Error('model request omitted its turn signal')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
expect(abortedDuringTurnEnd).toBe(false)
|
||||
expect(signal.aborted).toBe(false)
|
||||
expect(cancelNotifications).toBe(0)
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
|
||||
releaseFlush.resolve(undefined)
|
||||
await idle
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('records disposed when lifecycle teardown races an already-requested cancel', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('cancel-dispose-race'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const { agent } = handle
|
||||
|
||||
send(agent, 'go')
|
||||
await expect.poll(() => adapter.requests.length).toBe(1)
|
||||
agent.cancel({ kind: 'user' })
|
||||
await handle.dispose()
|
||||
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'prompt-submit',
|
||||
'system-prompt',
|
||||
'session-prefix',
|
||||
'pre-step',
|
||||
'request',
|
||||
'step-result',
|
||||
'post-step',
|
||||
'turn-continuation',
|
||||
'turn-stop',
|
||||
'tool',
|
||||
] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => {
|
||||
const adapter = new MockAdapter(stage === 'tool'
|
||||
? [toolCallResponse('blocked-tool', 'blocked', {})]
|
||||
: [textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId(`cooperative-${stage}`), { provider: 'mock', model: 'mock' })
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const blockUntilAbort = async (signal: AbortSignal): Promise<void> => {
|
||||
started.resolve(undefined)
|
||||
if (signal.aborted) return
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case 'prompt-submit':
|
||||
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'system-prompt':
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (context.agent === agent) {
|
||||
if (context.signal === undefined) throw new Error('turn assembly omitted its signal')
|
||||
await blockUntilAbort(context.signal)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'session-prefix':
|
||||
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'pre-step':
|
||||
ctx.on('agent/pre-step', async (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
})
|
||||
break
|
||||
case 'request':
|
||||
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'step-result':
|
||||
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'post-step':
|
||||
ctx.on('agent/post-step', async (subject, _turn, _step, signal) => {
|
||||
if (subject !== agent) return
|
||||
await blockUntilAbort(signal)
|
||||
throw new Error('post-step failed after cancellation')
|
||||
})
|
||||
break
|
||||
case 'turn-continuation':
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'turn-stop':
|
||||
ctx.on('agent/turn-stop', async (subject, _turn, signal) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
})
|
||||
break
|
||||
case 'tool':
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'blocked',
|
||||
description: 'wait for cancellation',
|
||||
parameters: {},
|
||||
execute: async (_args, exec) => {
|
||||
if (exec.signal === undefined) throw new Error('tool execution omitted its signal')
|
||||
await blockUntilAbort(exec.signal)
|
||||
return [{ type: 'text', text: 'cancelled' }]
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
send(agent, 'go')
|
||||
await started.promise
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,23 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
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
|
||||
}
|
||||
@@ -63,7 +73,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
|
||||
// Plugin rewrites the message: replaces the text AND adds a tool call.
|
||||
let rewritten = false
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => {
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => {
|
||||
if (rewritten) return next()
|
||||
rewritten = true
|
||||
return {
|
||||
@@ -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[] = []
|
||||
@@ -204,7 +214,7 @@ describe('successful provider completion survives agent/step-result failure', ()
|
||||
})
|
||||
|
||||
describe('abort during tool execution ends the turn', () => {
|
||||
it('balances an aborted tool batch through context, steering, and post-step before closing', async () => {
|
||||
it('balances a cancelled tool batch through context and post-step before closing', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
// model asks for two tool calls in one step
|
||||
[
|
||||
@@ -229,8 +239,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
[{ type: 'text', text: 'steering before abort' }],
|
||||
{ source: { kind: 'plugin', plugin: 'abort-test' } },
|
||||
)
|
||||
// Exercise bare step abort without `cancel()` clearing queued work.
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
@@ -259,7 +268,10 @@ describe('abort during tool execution ends the turn', () => {
|
||||
case 'assistant/message': order.push('assistant/message'); break
|
||||
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
|
||||
case 'tool/result': {
|
||||
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
|
||||
const outcome = event.data.error?.code === TOOL_ABORTED
|
||||
|| event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH
|
||||
? 'aborted'
|
||||
: 'completed'
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
@@ -290,25 +302,29 @@ describe('abort during tool execution ends the turn', () => {
|
||||
expect(order).toEqual([
|
||||
'assistant/message',
|
||||
'tool/call:c1',
|
||||
'tool/result:c1:real',
|
||||
'tool/result:c1:aborted',
|
||||
'tool/call:c2',
|
||||
'tool/result:c2:synthetic-aborted',
|
||||
'tool/result:c2:aborted',
|
||||
'context/message',
|
||||
'steering/message',
|
||||
'agent/post-step',
|
||||
'step/end',
|
||||
'turn/end:aborted',
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
const calls = agent.session.events.filter(event => event.type === 'tool/call')
|
||||
const results = agent.session.events.filter(event => event.type === 'tool/result')
|
||||
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false })
|
||||
expect(results[0]!.data).toMatchObject({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
expect(results[1]!.data).toMatchObject({
|
||||
callId: CallId('c2'),
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -322,7 +338,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
@@ -375,7 +391,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'aborted' }]
|
||||
},
|
||||
}))
|
||||
@@ -468,7 +484,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
@@ -507,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => {
|
||||
if (!steeredOnce) {
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'one more thing' }])
|
||||
@@ -581,26 +597,6 @@ describe('steering from late extension points is never stranded', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
|
||||
})
|
||||
|
||||
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
|
||||
const adapter = new MockAdapter(['hang', textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.steer([{ type: 'text', text: 'redirect' }])
|
||||
// Abort ONLY the in-flight step, via its AbortController directly — NOT
|
||||
// cancel(), which clears the inbox and would drop the queued steering this
|
||||
// test proves survives a step abort. There is no public step-only abort
|
||||
// verb (cancel() is the only public stop primitive), so reach the private
|
||||
// controller the loop registered.
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// a new turn ran with the steering content delivered as a message
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin exceptions are contained', () => {
|
||||
@@ -757,7 +753,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal) => {
|
||||
return { ...config, provider: 'mock', model: 'mock' }
|
||||
})
|
||||
|
||||
@@ -1041,7 +1037,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 +1046,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,10 +1464,10 @@ 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) => ({
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text: 'injected' }],
|
||||
}))
|
||||
@@ -1505,7 +1501,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 +1552,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) {
|
||||
@@ -1574,7 +1570,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
agent.cancel('user cancelled during assembly')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
releaseAssemble()
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -1586,15 +1582,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
|
||||
kind: 'aborted',
|
||||
reason: 'user cancelled during assembly',
|
||||
})
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
|
||||
@@ -1611,7 +1604,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 +1655,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 () => {
|
||||
@@ -1679,7 +1672,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel('user cancelled')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
releasePreStep()
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -1690,10 +1683,10 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
||||
@@ -1711,7 +1704,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) {
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('toError normalization', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw { code: 500 } // non-Error throw, goes through runStep catch
|
||||
@@ -200,7 +200,7 @@ describe('coded error data emission', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new LlmError('server overloaded', 'RATE_LIMIT')
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('agent/prompt-submit', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => {
|
||||
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
return next()
|
||||
})
|
||||
@@ -182,7 +182,7 @@ describe('agent/prompt-submit', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
|
||||
})
|
||||
@@ -497,7 +497,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let forced = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise<ContinuationDecision> => {
|
||||
if (!forced) {
|
||||
forced = true
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
|
||||
@@ -662,7 +662,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
)
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
|
||||
return 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/)
|
||||
})
|
||||
})
|
||||
@@ -231,7 +231,7 @@ describe('agent loop', () => {
|
||||
assembly.variables['model'] = 'mock'
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
|
||||
return { ...config, provider: 'mock', model: 'mock' }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
|
||||
@@ -527,7 +527,7 @@ describe('agent loop', () => {
|
||||
|
||||
let steps = 0
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
|
||||
if (steps < 3) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
@@ -566,7 +566,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
|
||||
// The seed is frozen — config is not a mutable per-call knob; a switch
|
||||
// is proposed by returning a replacement, and the loop logs it.
|
||||
expect(Object.isFrozen(config)).toBe(true)
|
||||
@@ -692,10 +692,10 @@ describe('agent loop', () => {
|
||||
// wait until the stream is hanging, then cancel
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
agent.cancel('user interrupt')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
})
|
||||
|
||||
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
|
||||
@@ -732,7 +732,7 @@ describe('agent loop', () => {
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
// Force exactly one continuation (step 1 → step 2), then defer to default
|
||||
// (step 2 is a plain stop with no tool calls → stops).
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
|
||||
if (steps < 2) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
@@ -884,7 +884,7 @@ describe('agent loop', () => {
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let stepResults = 0
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, message, _signal, next) => {
|
||||
stepResults += 1
|
||||
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
|
||||
return next()
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('request stability across the loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
|
||||
if (!injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
@@ -243,7 +243,7 @@ describe('request stability across the loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
|
||||
const config = await next()
|
||||
// next() resolves the SAME frozen seed — in-place shaping after
|
||||
// delegation is unrepresentable, so a "mutate what next() returned"
|
||||
@@ -280,7 +280,7 @@ describe('request stability across the loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
|
||||
send(agent, 'again')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
send(agent)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
await postStepEntered
|
||||
agent.cancel('cancelled during max-tokens post-step')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
|
||||
@@ -212,7 +212,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
})
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } },
|
||||
data: { reason: { kind: 'aborted' } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -587,7 +587,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
await recoveryEntered
|
||||
if (action === 'cancel') {
|
||||
agent.cancel('cancelled during recovery')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
} else {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -596,7 +596,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } },
|
||||
data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
order.push('agent/created')
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
expect(() => { agent.cancel('now live') }).not.toThrow()
|
||||
expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
|
||||
order.push('agent/session-start')
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
@@ -461,7 +461,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/message') {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted')
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -476,12 +476,12 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
isError: e.data.isError,
|
||||
error: e.data.error,
|
||||
}))).toEqual([
|
||||
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
|
||||
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
|
||||
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
])
|
||||
})
|
||||
|
||||
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
|
||||
it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||
textResponse('should never be requested'),
|
||||
@@ -492,24 +492,25 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.callId === CallId('c1')) {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled')
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
|
||||
callId: e.data.callId,
|
||||
isError: e.data.isError,
|
||||
error: e.data.error,
|
||||
}))).toEqual([
|
||||
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
])
|
||||
})
|
||||
|
||||
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
|
||||
@@ -528,7 +529,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now')
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
gated.release('2')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -540,8 +541,8 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
|
||||
.toEqual([
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
|
||||
expect(settled.map(e => e.type))
|
||||
@@ -574,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier')
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
gated.release('2')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -583,6 +584,6 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -50,7 +60,7 @@ describe('agent/turn-stop', () => {
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
|
||||
const downstream = await next()
|
||||
if (subject === agent && !steered) {
|
||||
steered = true
|
||||
|
||||
@@ -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,13 +2,15 @@
|
||||
|
||||
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.
|
||||
|
||||
### Public API
|
||||
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
||||
@@ -44,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
|
||||
|
||||
@@ -57,7 +59,7 @@ The handle every plugin programs against:
|
||||
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
|
||||
- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
|
||||
- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification.
|
||||
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
30
packages/core/agent/src/cancellation.ts
Normal file
30
packages/core/agent/src/cancellation.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */
|
||||
|
||||
import type { AgentInterruptReason } from './types.ts'
|
||||
|
||||
/**
|
||||
* Read a supported agent interruption from an explicitly supplied signal.
|
||||
* Unknown reasons return `undefined`; ambient initiator identity does not grant
|
||||
* cancellation authority.
|
||||
* @param signal - the current turn's explicit control signal.
|
||||
* @returns its canonical reason, or `undefined` while live or unsupported.
|
||||
*/
|
||||
export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined {
|
||||
if (!signal.aborted) return undefined
|
||||
const reason: unknown = signal.reason
|
||||
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return undefined
|
||||
const prototype = Object.getPrototypeOf(reason) as unknown
|
||||
const keys = Reflect.ownKeys(reason)
|
||||
if ((prototype !== Object.prototype && prototype !== null)
|
||||
|| keys.length !== 1 || keys[0] !== 'kind') return undefined
|
||||
switch ((reason as { readonly kind?: unknown }).kind) {
|
||||
case 'user':
|
||||
return Object.freeze({ kind: 'user' })
|
||||
case 'parent':
|
||||
return Object.freeze({ kind: 'parent' })
|
||||
case 'disposed':
|
||||
return Object.freeze({ kind: 'disposed' })
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -115,8 +115,9 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
* Build the prompt assembly context with agent and scope set together, so
|
||||
* agent-scoped prompt and tool contributions cannot be silently omitted.
|
||||
* @param agent - the agent the assembly is for.
|
||||
* @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
|
||||
* @returns the context to pass to `assemble()`.
|
||||
*/
|
||||
export function assembleContextFor(agent: Agent): AssembleContext {
|
||||
return { agent, scope: agent }
|
||||
export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext {
|
||||
return { agent, scope: agent, ...signal === undefined ? {} : { signal } }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentOptions } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { agentInterruptReasonOf } from './cancellation.ts'
|
||||
export * from './llm-target.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
|
||||
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))
|
||||
66
packages/core/agent/src/llm-target.ts
Normal file
66
packages/core/agent/src/llm-target.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Agent-scoped provider/model target snapshot shared by interactive front doors.
|
||||
* @module @deepseek-ai/dsh-agent/llm-target
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Complete provider/model route selected for one live agent. */
|
||||
export interface AgentLlmTarget {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Mutable selection plus the target captured for the current step. */
|
||||
export interface AgentLlmTargetRef {
|
||||
/** Target selected for the next step that enters prompt assembly. */
|
||||
current: AgentLlmTarget | undefined
|
||||
/** Target captured when the current step entered prompt assembly. */
|
||||
assembled: AgentLlmTarget | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Couple one mutable target to agent-scoped prompt assembly and request routing.
|
||||
* Prompt assembly snapshots the selected pair before delegating, then applies
|
||||
* both prompt variables and request config to that snapshot so a concurrent
|
||||
* switch takes effect on a later step instead of splitting the two surfaces.
|
||||
*
|
||||
* @param agentCtx - The target agent's scoped context.
|
||||
* @param target - Mutable selection owned by the calling front door.
|
||||
* @returns Disposer for both scoped waterfall listeners.
|
||||
*/
|
||||
export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void {
|
||||
const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const selected = target.current
|
||||
const assembled = await next()
|
||||
target.assembled = selected
|
||||
if (selected === undefined) return assembled
|
||||
return {
|
||||
...assembled,
|
||||
variables: {
|
||||
...assembled.variables,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
},
|
||||
}
|
||||
})
|
||||
const disposeRequest = agentCtx.on(
|
||||
'agent/request',
|
||||
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
return selected === undefined ? resolved : {
|
||||
...resolved,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
}
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
disposeAssembly()
|
||||
disposeRequest()
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,14 @@ export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Stable runtime cause accepted by {@link Agent.cancel}. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
@@ -125,12 +133,14 @@ export interface Agent {
|
||||
|
||||
/**
|
||||
* Clear all queued and steering work, including items waiting to start, and
|
||||
* abort the active step. An effective call first emits `agent/cancel-requested`
|
||||
* with the resolved reason. That reason is preserved across pre-step and active
|
||||
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
|
||||
* abort the active turn. An effective call first emits
|
||||
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
|
||||
* for the active turn, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
|
||||
* and does not arm later work. The active turn snapshots and freezes the cause.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
cancel(cause?: AgentCancelCause): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
@@ -181,14 +191,14 @@ declare module 'cordis' {
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
/**
|
||||
* Effective broad cancellation was requested, before queued/steering work
|
||||
* is cleared or the active step is aborted. This observe-only notification
|
||||
* is cleared or the active turn is aborted. This observe-only notification
|
||||
* cannot veto cancellation; listener failures are contained.
|
||||
* @param agent - the agent whose current work is being cancelled.
|
||||
* @param reason - resolved cancellation reason, including the default.
|
||||
* @param cause - resolved typed cancellation cause, including the default.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, reason: string): void
|
||||
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
|
||||
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
@@ -220,14 +230,17 @@ declare module 'cordis' {
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default.
|
||||
* message. Call `next()` for the unchanged default. The signal controls only
|
||||
* this turn; listeners may cooperate with it but must not retain it to
|
||||
* control another turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param content - the claimed message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Replace the frozen call configuration. Model-visible content must use
|
||||
* logged channels; this seam cannot mutate messages. Injection here joins
|
||||
@@ -236,10 +249,12 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param config - the config the loop would use (frozen); return a replacement to switch.
|
||||
* @param signal - the current turn's explicit abort signal; ambient
|
||||
* initiator identity does not imply liveness or cancellation authority.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Compose request-only messages placed before derived history. The frozen
|
||||
* result is computed once per loop instance, logged on its anchoring request
|
||||
@@ -251,7 +266,7 @@ declare module 'cordis' {
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen seed; return an extended replacement.
|
||||
* @param signal - aborts composition when the step is torn down.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
@@ -262,10 +277,11 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
|
||||
/**
|
||||
* Awaited serial checkpoint after the response, real or synthetic tool
|
||||
* results, injected context, and steering are durable but before `step/end`.
|
||||
@@ -299,20 +315,22 @@ declare module 'cordis' {
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Monotonic terminal-stop checkpoint after continuation and steering are
|
||||
* folded; a stop remains authoritative through turn close and flush:
|
||||
* steering queued in that window is discarded, while ordinary sends survive.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user