Merge remote-tracking branch 'origin/master' into codex/enforce-tool-cancellation
# Conflicts: # docs/cookbook/adding-a-tool.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/bash/tool-bash/src/index.ts # packages/core/agent-loop/README.md # packages/core/tools/README.md # packages/core/tools/tests/scoped.spec.ts # packages/fs/tool-fs-search/tests/tools.spec.ts # website/zh-CN/api/harness/events.md # website/zh-CN/api/harness/tools.md
This commit is contained in:
@@ -31,7 +31,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
|
||||
@@ -16,7 +16,7 @@ Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
|
||||
@@ -25,11 +25,13 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
|
||||
@@ -25,16 +25,15 @@
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "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"
|
||||
}
|
||||
|
||||
@@ -7,52 +7,39 @@
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is not configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and the `workspace-write` boundary root — is NOT here: it
|
||||
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
|
||||
* home both enforcing families read, so bash and fs can never confine to
|
||||
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
|
||||
* config, not this executor's.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
/** File-sandbox mode commands run under (default: `read-only`). */
|
||||
mode?: SandboxMode
|
||||
/**
|
||||
* Root directory `workspace-write` mode may write under (default: the
|
||||
* executor's default working directory — `cwd`, else `process.cwd()`).
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local executor and requires a
|
||||
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
|
||||
* the fallback, while a session override or approved one-shot escalation may
|
||||
* select each call's mode. The prompt does not state the standing mode;
|
||||
* `result.sandbox` reports the mode and enforcement actually used.
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
|
||||
* unchanged. The policy default (mode + workspace root) is the fallback,
|
||||
* while a session override or approved one-shot escalation may select each
|
||||
* call's mode. The prompt does not state the standing mode; `result.sandbox`
|
||||
* reports the mode and enforcement actually used.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox']
|
||||
static inject = ['sandbox', 'sandboxPolicy']
|
||||
|
||||
// The sandbox-specific fields intersect the local executor's Config as an
|
||||
// inline schema call: the config catalog walks `static Config` statically.
|
||||
static override Config: z<Config> = z.intersect([
|
||||
LocalBashExecutor.Config,
|
||||
z.object({
|
||||
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
||||
workspaceRoot: z.string(),
|
||||
}),
|
||||
])
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
@@ -71,9 +58,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
|
||||
this.mode = config.mode as SandboxMode
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
|
||||
// The sandbox default (mode + workspaceRoot) is the one shared policy home
|
||||
// both enforcing families read; injecting sandboxPolicy guarantees it is
|
||||
// constructed first. workspaceRoot arrives already resolved absolute.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -40,7 +41,8 @@ async function tempDir(base: string): Promise<string> {
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
@@ -39,7 +40,11 @@ const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
* per test) and the executor under test on top of it.
|
||||
*/
|
||||
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
|
||||
async function setup(
|
||||
config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
|
||||
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
|
||||
) {
|
||||
const { mode, workspaceRoot, ...execConfig } = config
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
@@ -49,7 +54,11 @@ async function setup(config: Config = {}, behavior: (argv: readonly string[], po
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
|
||||
await ctx.plugin(SandboxPolicyService, {
|
||||
...mode !== undefined ? { mode } : {},
|
||||
...workspaceRoot !== undefined ? { workspaceRoot } : {},
|
||||
})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
@@ -84,14 +93,14 @@ describe('the provider hand-off', () => {
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot wins over cwd', async () => {
|
||||
it('an explicit workspaceRoot on the policy wins', async () => {
|
||||
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -39,7 +40,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
@@ -26,6 +23,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
|
||||
@@ -23,12 +23,10 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Per-session sandbox-mode override stored as log-only events. Folding the log
|
||||
* isolates sessions and survives replay; the tool stamps the override onto
|
||||
* each call unless an approved one-shot escalation outranks it, and the
|
||||
* executor default applies when neither exists. The model receives neither the
|
||||
* event nor a standing-mode notice; denial results name the effective mode.
|
||||
* @module dsh-bash/session-mode
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Durable log-only sandbox-mode override; never a surface event or model
|
||||
* message. Execution and ACP option reporting fold the latest event through
|
||||
* {@link effectiveSandboxMode} without adding a prompt notice.
|
||||
*/
|
||||
'bash/sandbox-mode': { mode: SandboxMode }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
|
||||
* the log, or undefined when the session never switched and callers should use
|
||||
* the executor default. Replay needs no separate catch-up state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the mode of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'bash/sandbox-mode') return event.data.mode
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one `bash/sandbox-mode` event as the only override write path.
|
||||
* Execution and ACP option reporting fold it on read; prompt assembly does not
|
||||
* consume it.
|
||||
* @param session - the session the override belongs to.
|
||||
* @param mode - the mode every subsequent bash call in this session runs
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setSandboxMode(session: Session, mode: SandboxMode): void {
|
||||
session.append('bash/sandbox-mode', { mode })
|
||||
}
|
||||
@@ -16,9 +16,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,15 +23,16 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-home": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^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",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-home": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
@@ -54,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
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 { processOutcome } from './background.ts'
|
||||
@@ -226,24 +227,11 @@ function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
if (args.sandbox_permissions !== undefined && args.justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (args.justification !== undefined && args.sandbox_permissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (args.justification !== undefined && args.justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
|
||||
// the shared rule both enforcing families validate identically.
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
}
|
||||
|
||||
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
@@ -346,35 +334,32 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes, delegating the shared fail-closed sequence (strict
|
||||
* widening, channel resolution, outcome mapping) to
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval ingredients
|
||||
* — the seam is consumed opportunistically (`ctx.get`) so a deployment
|
||||
* without it degrades per call.
|
||||
*/
|
||||
const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
signal: exec.signal,
|
||||
})
|
||||
switch (outcome) {
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
return approveEscalation(
|
||||
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
||||
{
|
||||
approver: ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName: 'bash',
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
|
||||
@@ -417,7 +402,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
validateBashArgs(args)
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
@@ -42,10 +43,10 @@ export function renderResult(
|
||||
const markers: string[] = []
|
||||
// Keep the exit marker last because parseExitStatus anchors there.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
||||
// Hint only when the composition exposes escalation, before the final exit marker.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
markers.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
|
||||
@@ -84,9 +85,9 @@ export function renderProcessRead(
|
||||
if (sandbox?.runnerFailed) {
|
||||
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
||||
} else if (sandbox?.denied) {
|
||||
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
|
||||
notices.push(sandboxDenialMarker(sandbox.mode))
|
||||
if (escalationModes.length > 0) {
|
||||
notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
notices.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
|
||||
@@ -23,7 +23,9 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
||||
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot })
|
||||
if (sessionRoot !== undefined) {
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
|
||||
}
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
|
||||
@@ -183,7 +183,7 @@ async function setupSandboxed(withApproval = false) {
|
||||
|
||||
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
|
||||
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
|
||||
if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } })
|
||||
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
|
||||
const id = SessionId('sandbox-session')
|
||||
return {
|
||||
id,
|
||||
@@ -556,7 +556,7 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
|
||||
const malformed = sandboxAgent()
|
||||
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
|
||||
type: 'bash/sandbox-mode',
|
||||
type: 'sandbox/mode',
|
||||
data: { mode: 'unknown-mode' },
|
||||
})
|
||||
expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
|
||||
@@ -613,7 +613,7 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
|
||||
const result = await call(ctx, 'bash', escalate, sandboxAgent())
|
||||
expect(text(result)).toContain('unreachable variant in ApprovalOutcome')
|
||||
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,10 +129,12 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
}, 15_000)
|
||||
|
||||
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
|
||||
const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 })
|
||||
// Keep the binding delay above the compute allowance while leaving enough
|
||||
// headroom for worker bootstrap on loaded CI hosts.
|
||||
const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: 'return await tools.slow({})',
|
||||
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }),
|
||||
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('slow-done')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# compact/ — compaction capability family
|
||||
|
||||
A three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
|
||||
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
|
||||
|
||||
@@ -9,13 +9,14 @@ 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.
|
||||
- **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; a single unit larger than the budget remains out of scope.
|
||||
- **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.
|
||||
- **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 and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted 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 replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
@@ -50,7 +51,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
```
|
||||
|
||||
Loading the plugin registers `ctx.compact`. 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.
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -58,7 +59,7 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units.
|
||||
After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. A checkpoint replaces the selected older range and is followed by the retained recent units.
|
||||
|
||||
##### Conversation checkpoint preamble
|
||||
|
||||
@@ -68,7 +69,7 @@ This is an automatically generated checkpoint condensing an earlier span of the
|
||||
|
||||
#### Token effect
|
||||
|
||||
The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
|
||||
Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. The replacement reduces future input history rather than appending a second copy. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -144,7 +145,7 @@ Prefix-stable for auxiliary calls while this instruction and the summarizer rout
|
||||
|
||||
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
|
||||
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
|
||||
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
|
||||
- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
|
||||
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
|
||||
@@ -27,8 +27,14 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
@@ -41,8 +47,10 @@
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 { 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 { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
@@ -93,27 +95,41 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => {
|
||||
if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE
|
||||
|| retryAttempt >= this.config.maxOverflowRetries
|
||||
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()
|
||||
|
||||
let generation: number
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
let result: CompactionResult | null
|
||||
try {
|
||||
generation = agent.session.surface.replaceGeneration
|
||||
result = await this.compactIfNeeded(agent, 'context-overflow', signal)
|
||||
} catch (recoveryError: unknown) {
|
||||
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
|
||||
// A model-free prune can land before later summary work fails. That
|
||||
// durable reduction is sufficient retry proof; do not discard it just
|
||||
// because the optional second phase threw. Cancellation still wins.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
|
||||
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
|
||||
ctx.logger.warn(
|
||||
`context-overflow compaction failed after durable surface progress: ${message}; `
|
||||
+ 'retrying from the replacement surface',
|
||||
)
|
||||
return { action: 'retry' }
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`context-overflow compaction failed: ${message}; preserving the original request error`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
|
||||
`context-overflow compaction failed: ${message}; ${signal.aborted
|
||||
? 'cancellation prevents retry'
|
||||
: 'preserving the original request error'}`,
|
||||
)
|
||||
return next()
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
|
||||
if (signal.aborted || result === null
|
||||
if (signal.aborted
|
||||
|| agent.session.surface.replaceGeneration <= generation) return next()
|
||||
logResult(result, 'context overflow recovery')
|
||||
if (result !== null) logResult(result, 'context overflow recovery')
|
||||
return { action: 'retry' }
|
||||
})
|
||||
}
|
||||
@@ -142,7 +158,7 @@ export class BasicCompactService extends CompactService {
|
||||
* @param agent - agent whose latest durable routed request is measured.
|
||||
* @param trigger - normal post-step pressure or context-overflow recovery.
|
||||
* @param signal - live turn cancellation signal forwarded to summarization.
|
||||
* @returns the latest compaction result, or `null` when no check/work applies.
|
||||
* @returns the latest summary compaction result, or `null` when no summary ran.
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
@@ -152,22 +168,34 @@ export class BasicCompactService extends CompactService {
|
||||
const model = routedModel(agent.session)
|
||||
if (model === undefined) return null
|
||||
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': {
|
||||
const 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)
|
||||
}
|
||||
case 'context-overflow':
|
||||
break
|
||||
case 'pressure':
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(trigger, 'compaction trigger')
|
||||
}
|
||||
|
||||
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||
let measurement = meter.measure(agent.session)
|
||||
// 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.
|
||||
const prune = this.ctx.get('toolResultPrune')
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
|
||||
if (trigger === 'context-overflow') {
|
||||
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
|
||||
|
||||
let result: CompactionResult | null = null
|
||||
|
||||
@@ -141,14 +141,10 @@ export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
/** Map a terminal summarization finish to its fail-closed error. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error = new Error(finish.message) as Error & { code?: string }
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error('summarization stream aborted') as Error & { code?: string }
|
||||
error.code = 'ABORTED'
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
|
||||
@@ -7,9 +7,10 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
|
||||
import { resolveConfig } 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, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const SIGNAL = new AbortController().signal
|
||||
@@ -97,6 +98,43 @@ function toolConversation(): Session {
|
||||
return session
|
||||
}
|
||||
|
||||
/** One closed routed tool step followed by an open turn for rewrite events. */
|
||||
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
|
||||
const session = new Session(SessionId(`oversized-tool-${chars}`))
|
||||
const callId = CallId('oversized')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
if (withCompactablePrompt) {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'older history '.repeat(200) }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'X'.repeat(chars) }],
|
||||
isError: false,
|
||||
meta: { presentation: 'preserved' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return session
|
||||
}
|
||||
|
||||
class TestCompactService extends BasicCompactService {
|
||||
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
|
||||
summaryProvider = 'summary-provider'
|
||||
@@ -416,6 +454,78 @@ describe('pressure measurement and retention', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('optional model-free tool-result pruning', () => {
|
||||
const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
|
||||
|
||||
it('does not prune a below-pressure session opportunistically', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
const prune = new ToolResultPruneService(ctx, pruneConfig)
|
||||
const compact = new TestCompactService(ctx, {
|
||||
auto: false,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 100,
|
||||
})
|
||||
const session = oversizedToolResult()
|
||||
const pruneSession = vi.spyOn(prune, 'pruneSession')
|
||||
|
||||
expect(await compactIfNeeded(compact, session)).toBeNull()
|
||||
expect(pruneSession).not.toHaveBeenCalled()
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
expect(session.surface.replaceGeneration).toBe(0)
|
||||
})
|
||||
|
||||
it('skips LLM summarization when pruning alone clears pressure', async () => {
|
||||
const ctx = createContext(1_000)
|
||||
void new ToolResultPruneService(ctx, pruneConfig)
|
||||
const compact = new TestCompactService(ctx, {
|
||||
auto: false,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
})
|
||||
const session = oversizedToolResult()
|
||||
|
||||
expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
|
||||
expect(await compactIfNeeded(compact, session)).toBeNull()
|
||||
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
})
|
||||
|
||||
it('summarizes the pruned surface when pruning is insufficient', async () => {
|
||||
const ctx = createContext(2_000)
|
||||
void new ToolResultPruneService(ctx, pruneConfig)
|
||||
const compact = new TestCompactService(ctx, {
|
||||
auto: false,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
})
|
||||
const session = toolConversation()
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
it('retains the original compact-basic behavior without the optional plugin', async () => {
|
||||
const ctx = createContext(2_000)
|
||||
const compact = new TestCompactService(ctx, {
|
||||
auto: false,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
})
|
||||
const session = oversizedToolResult(3_000, true)
|
||||
|
||||
expect(await compactIfNeeded(compact, session)).not.toBeNull()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
const original = session.events.find(event => event.type === 'tool/result')
|
||||
expect(original?.type === 'tool/result' && original.data.content[0])
|
||||
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
|
||||
expect(session.events.filter(event =>
|
||||
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compaction region transaction', () => {
|
||||
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
|
||||
const compact = service()
|
||||
@@ -762,9 +872,9 @@ describe('default one-shot summarizer', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
|
||||
[{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
|
||||
[{ kind: 'aborted' }, 'ABORTED', /aborted/],
|
||||
[{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/],
|
||||
[{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/],
|
||||
[{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/],
|
||||
[{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
|
||||
] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
|
||||
'rejects terminal finish %#',
|
||||
@@ -802,7 +912,9 @@ describe('automatic listener and loader composition', () => {
|
||||
signal = SIGNAL,
|
||||
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
|
||||
): Promise<{ action: 'fail' | 'retry' }> {
|
||||
return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next)
|
||||
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)
|
||||
}
|
||||
|
||||
function overflow(message = 'provider overflow'): Error & { code: string } {
|
||||
@@ -876,6 +988,89 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(session.surface.nodes).toContain(retainedSeq)
|
||||
})
|
||||
|
||||
it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
void new ToolResultPruneService(ctx, {
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 900,
|
||||
})
|
||||
const session = oversizedToolResult()
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('continues overflow recovery with summarization on the pruned surface', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
void new ToolResultPruneService(ctx, {
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 900,
|
||||
})
|
||||
const session = toolConversation()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('retries from a durable prune when later overflow summarization throws', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
void new ToolResultPruneService(ctx, {
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 900,
|
||||
})
|
||||
compact.error = new Error('summary unavailable after prune')
|
||||
const session = oversizedToolResult(3_000, true)
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
|
||||
.toMatchObject({ error: 'summary unavailable after prune' })
|
||||
expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
|
||||
})
|
||||
|
||||
it('lets cancellation win when summary throws after a durable prune', async () => {
|
||||
const ctx = createContext(10_000)
|
||||
const controller = new AbortController()
|
||||
void new ToolResultPruneService(ctx, {
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 900,
|
||||
})
|
||||
compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
|
||||
compact.error = new Error('summary cancelled after prune')
|
||||
const session = oversizedToolResult(3_000, true)
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
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'
|
||||
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,10 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
readonly conversationRequests: GenerateOptions[] = []
|
||||
readonly summaryRequests: GenerateOptions[] = []
|
||||
|
||||
constructor(private readonly delivery: 'thrown' | 'in-band') {
|
||||
constructor(
|
||||
private readonly delivery: 'thrown' | 'in-band',
|
||||
private readonly transientAfterOverflow = false,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -83,12 +87,17 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
message: 'request too large for model context',
|
||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
failure: {
|
||||
message: 'request too large for model context',
|
||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
},
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.transientAfterOverflow && this.conversationRequests.length === 2) {
|
||||
throw new LlmError('temporary provider outage', 'SERVER')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
@@ -134,6 +143,29 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function seedOverflowHistory(agent: Agent): void {
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
agent.session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn, step: 1 })
|
||||
agent.session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn, step: 1 })
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
}
|
||||
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('uses the model actually routed by agent/request for post-step pressure', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
@@ -241,26 +273,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
agent.session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn, step: 1 })
|
||||
agent.session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn, step: 1 })
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
seedOverflowHistory(agent)
|
||||
|
||||
agent.send([{ type: 'text', text: 'continue from history' }])
|
||||
await agent.whenIdle()
|
||||
@@ -299,4 +312,47 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps context-overflow and transient retry budgets independent in one sequence', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter('thrown', true)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(LlmRetry, {
|
||||
maxTransientRetries: 1,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
jitterRatio: 0,
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
maxTokens: 64,
|
||||
compactionRetries: 0,
|
||||
maxOverflowRetries: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
|
||||
seedOverflowHistory(agent)
|
||||
agent.send([{ type: 'text', text: 'continue from history' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(3)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
|
||||
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
|
||||
.toEqual([1, 2, 3])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmService],
|
||||
['@deepseek-ai/dsh-token-meter', TokenMeterService],
|
||||
['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
|
||||
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
|
||||
])
|
||||
context.loader.internal = {
|
||||
@@ -50,12 +52,17 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('loads the flat token-meter and compact-basic YAML shape', async () => {
|
||||
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
|
||||
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',
|
||||
' headChars: 20',
|
||||
' tailChars: 10',
|
||||
"- name: '@deepseek-ai/dsh-compact-basic'",
|
||||
' config:',
|
||||
' thresholdRatio: 0.5',
|
||||
@@ -68,6 +75,7 @@ describe('real Loader composition', () => {
|
||||
.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,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "../../llm/token-meter" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../compact" }
|
||||
{ "path": "../compact" },
|
||||
{ "path": "../compact-tool-result-prune" }
|
||||
]
|
||||
}
|
||||
|
||||
60
packages/compact/compact-tool-result-prune/README.md
Normal file
60
packages/compact/compact-tool-result-prune/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# @deepseek-ai/dsh-compact-tool-result-prune
|
||||
|
||||
The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log.
|
||||
|
||||
This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable.
|
||||
|
||||
## Service API
|
||||
|
||||
`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection.
|
||||
|
||||
The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable.
|
||||
|
||||
`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster.
|
||||
|
||||
Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.
|
||||
|
||||
## Config
|
||||
|
||||
Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. |
|
||||
| `headChars` | no (default `4096`) | Leading Unicode code points retained. |
|
||||
| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. |
|
||||
|
||||
All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(ToolResultPruneService)
|
||||
}
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Pruned tool result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure.
|
||||
- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important.
|
||||
- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation.
|
||||
40
packages/compact/compact-tool-result-prune/package.json
Normal file
40
packages/compact/compact-tool-result-prune/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-tool-result-prune",
|
||||
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
77
packages/compact/compact-tool-result-prune/src/config.ts
Normal file
77
packages/compact/compact-tool-result-prune/src/config.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Configuration resolution for deterministic tool-result pruning. */
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts'
|
||||
|
||||
/** Fixed marker substituted for every removed middle span. */
|
||||
export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
|
||||
|
||||
/** Low-friction defaults for coding-agent tool output. */
|
||||
export const DEFAULTS: ResolvedConfig = deepFreeze({
|
||||
thresholdChars: 8192,
|
||||
headChars: 4096,
|
||||
tailChars: 1024,
|
||||
})
|
||||
|
||||
const CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'thresholdChars',
|
||||
'headChars',
|
||||
'tailChars',
|
||||
])
|
||||
|
||||
/**
|
||||
* Count Unicode code points without splitting surrogate pairs.
|
||||
* @param text - text to measure.
|
||||
* @returns the Unicode code-point count.
|
||||
*/
|
||||
export function codePointLength(text: string): number {
|
||||
return Array.from(text).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate pruning budgets.
|
||||
* @param config - raw plugin configuration.
|
||||
* @returns a detached deeply immutable configuration.
|
||||
*/
|
||||
export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!CONFIG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`ToolResultPruneConfig: unknown key "${key}" `
|
||||
+ '(allowed: thresholdChars, headChars, tailChars)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resolved: ResolvedConfig = {
|
||||
thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars,
|
||||
headChars: config.headChars ?? DEFAULTS.headChars,
|
||||
tailChars: config.tailChars ?? DEFAULTS.tailChars,
|
||||
}
|
||||
assertPositiveInteger('thresholdChars', resolved.thresholdChars)
|
||||
assertNonNegativeInteger('headChars', resolved.headChars)
|
||||
assertNonNegativeInteger('tailChars', resolved.tailChars)
|
||||
|
||||
const emittedChars = resolved.headChars
|
||||
+ codePointLength(PRUNE_MARKER)
|
||||
+ resolved.tailChars
|
||||
if (emittedChars > resolved.thresholdChars) {
|
||||
throw new Error(
|
||||
`ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) `
|
||||
+ `must be at most thresholdChars (${resolved.thresholdChars})`,
|
||||
)
|
||||
}
|
||||
return deepFreeze(structuredClone(resolved))
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
159
packages/compact/compact-tool-result-prune/src/index.ts
Normal file
159
packages/compact/compact-tool-result-prune/src/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Replay-safe, model-free tool-result pruning service.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-tool-result-prune
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
|
||||
import type {
|
||||
PrunedEntry,
|
||||
PruneResult,
|
||||
ResolvedConfig,
|
||||
ToolResultPruneConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
|
||||
export type {
|
||||
PrunedEntry,
|
||||
PruneResult,
|
||||
ResolvedConfig,
|
||||
ToolResultPruneConfig,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
toolResultPrune: ToolResultPruneService
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotCandidate {
|
||||
readonly seq: number
|
||||
readonly event: SessionEvent<'tool/result'>
|
||||
}
|
||||
|
||||
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
|
||||
export class ToolResultPruneService extends Service {
|
||||
static Config: z<ToolResultPruneConfig> = z.object({
|
||||
thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
|
||||
headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
|
||||
tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
|
||||
})
|
||||
|
||||
/** Resolved and immutable character budgets. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
|
||||
super(ctx, 'toolResultPrune')
|
||||
this.config = resolveConfig(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure text content in Unicode code points; non-text blocks cost zero.
|
||||
* @param blocks - tool-result content to measure.
|
||||
* @returns total Unicode code points across text blocks.
|
||||
*/
|
||||
measureContent(blocks: readonly ContentBlock[]): number {
|
||||
let chars = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') chars += codePointLength(block.text)
|
||||
}
|
||||
return chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an over-budget text middle while retaining rich-block order.
|
||||
* Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
|
||||
* boundary cannot split a surrogate pair. Grapheme clusters may still split.
|
||||
* @param blocks - original tool-result content.
|
||||
* @returns pruned content, or `null` when the text is within budget.
|
||||
*/
|
||||
pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
|
||||
const totalChars = this.measureContent(blocks)
|
||||
if (totalChars <= this.config.thresholdChars) return null
|
||||
|
||||
const removedStart = this.config.headChars
|
||||
const removedEnd = totalChars - this.config.tailChars
|
||||
const pruned: ContentBlock[] = []
|
||||
let consumed = 0
|
||||
let markerInserted = false
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'text') {
|
||||
pruned.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
const points = Array.from(block.text)
|
||||
const blockStart = consumed
|
||||
const blockEnd = blockStart + points.length
|
||||
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
|
||||
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
|
||||
const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
|
||||
const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
|
||||
if (marker.length > 0) markerInserted = true
|
||||
const text = points.slice(0, headEnd).join('')
|
||||
+ marker
|
||||
+ points.slice(tailStart).join('')
|
||||
if (text.length > 0) pruned.push({ ...block, text })
|
||||
consumed = blockEnd
|
||||
}
|
||||
|
||||
/* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
|
||||
if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
|
||||
const charsAfter = this.measureContent(pruned)
|
||||
/* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
|
||||
if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
|
||||
throw new Error('tool-result prune: replacement must be smaller and within threshold')
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune every over-budget tool result from one stable current-surface snapshot.
|
||||
* Each replacement preserves the complete event data except for `content`,
|
||||
* and points at the shadowed node for durable provenance and replay.
|
||||
* @param session - session whose current surface is rewritten.
|
||||
* @returns landed replacements and aggregate Unicode-code-point savings.
|
||||
* @throws when the session rejects a replacement; replacements committed
|
||||
* earlier in the pass remain durable.
|
||||
*/
|
||||
pruneSession(session: Session): PruneResult {
|
||||
const candidates: SnapshotCandidate[] = []
|
||||
for (const seq of [...session.surface.nodes]) {
|
||||
const event = session.events[seq]
|
||||
/* v8 ignore next -- surface seqs are validated contiguous log references. */
|
||||
if (event?.type === 'tool/result') candidates.push({ seq, event })
|
||||
}
|
||||
|
||||
const pruned: PrunedEntry[] = []
|
||||
let charsRemoved = 0
|
||||
for (const { seq, event } of candidates) {
|
||||
const content = this.pruneContent(event.data.content)
|
||||
if (content === null) continue
|
||||
const charsBefore = this.measureContent(event.data.content)
|
||||
const charsAfter = this.measureContent(content)
|
||||
const replacement = session.append('tool/result', {
|
||||
...event.data,
|
||||
content,
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: seq, end: seq },
|
||||
sourceEventSeqs: [seq],
|
||||
})
|
||||
pruned.push({
|
||||
originalSeq: seq,
|
||||
replacementSeq: replacement.seq,
|
||||
callId: event.data.callId,
|
||||
charsBefore,
|
||||
charsAfter,
|
||||
})
|
||||
charsRemoved += charsBefore - charsAfter
|
||||
}
|
||||
return { pruned, charsRemoved }
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolResultPruneService
|
||||
40
packages/compact/compact-tool-result-prune/src/types.ts
Normal file
40
packages/compact/compact-tool-result-prune/src/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Character-budget policy for deterministic tool-result pruning. */
|
||||
export interface ToolResultPruneConfig {
|
||||
/** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
|
||||
thresholdChars?: number
|
||||
/** Maximum leading Unicode code points retained. Defaults to `4096`. */
|
||||
headChars?: number
|
||||
/** Maximum trailing Unicode code points retained. Defaults to `1024`. */
|
||||
tailChars?: number
|
||||
}
|
||||
|
||||
/** Validated, detached, deeply immutable pruning configuration. */
|
||||
export interface ResolvedConfig {
|
||||
readonly thresholdChars: number
|
||||
readonly headChars: number
|
||||
readonly tailChars: number
|
||||
}
|
||||
|
||||
/** Provenance and size accounting for one landed surface replacement. */
|
||||
export interface PrunedEntry {
|
||||
/** Full-fidelity tool-result event shadowed by the replacement. */
|
||||
readonly originalSeq: number
|
||||
/** Newly appended pruned tool-result event. */
|
||||
readonly replacementSeq: number
|
||||
/** Tool call shared by the original and replacement. */
|
||||
readonly callId: CallId
|
||||
/** Original text size in Unicode code points. */
|
||||
readonly charsBefore: number
|
||||
/** Replacement text size in Unicode code points. */
|
||||
readonly charsAfter: number
|
||||
}
|
||||
|
||||
/** Aggregate outcome of one stable-surface pruning pass. */
|
||||
export interface PruneResult {
|
||||
/** Replacements in the snapshotted surface order. */
|
||||
readonly pruned: readonly PrunedEntry[]
|
||||
/** Total Unicode code points removed across replacements. */
|
||||
readonly charsRemoved: number
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe('compact-tool-result-prune real Loader composition', () => {
|
||||
it('loads and resolves the flat YAML plugin shape', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
|
||||
' config:',
|
||||
' thresholdChars: 100',
|
||||
' headChars: 20',
|
||||
' tailChars: 10',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') {
|
||||
throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
}
|
||||
return ToolResultPruneService
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
expect(context.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
|
||||
expect(context.toolResultPrune.config).toEqual({
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale config after plugin schema normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(ToolResultPruneService, {
|
||||
maxChars: 100,
|
||||
} as never)).rejects.toThrow(/unknown key "maxChars"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
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 ToolResultPruneService, {
|
||||
codePointLength,
|
||||
DEFAULTS,
|
||||
PRUNE_MARKER,
|
||||
resolveConfig,
|
||||
} from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
const MODEL = 'test-model'
|
||||
const SMALL: ToolResultPruneConfig = {
|
||||
thresholdChars: 50,
|
||||
headChars: 4,
|
||||
tailChars: 3,
|
||||
}
|
||||
|
||||
function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
|
||||
return new ToolResultPruneService(new Context(), config)
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
turn: number,
|
||||
call: string,
|
||||
content: ContentBlock[],
|
||||
extra: Record<string, unknown> = {},
|
||||
): number {
|
||||
const callId = CallId(call)
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId,
|
||||
content,
|
||||
isError: false,
|
||||
...extra,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
return result.seq
|
||||
}
|
||||
|
||||
describe('tool-result pruning configuration', () => {
|
||||
it('resolves detached immutable defaults and partial overrides', () => {
|
||||
const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
|
||||
const resolved = resolveConfig(raw)
|
||||
raw.headChars = 1
|
||||
expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 })
|
||||
expect(Object.isFrozen(resolved)).toBe(true)
|
||||
expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 })
|
||||
expect(Object.isFrozen(DEFAULTS)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects stale keys, invalid scalars, and an output budget above threshold', () => {
|
||||
const bad = [
|
||||
[{ thresholdChars: 0 }, /thresholdChars .* positive integer/],
|
||||
[{ headChars: -1 }, /headChars .* non-negative integer/],
|
||||
[{ tailChars: 1.5 }, /tailChars .* non-negative integer/],
|
||||
[{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/],
|
||||
[{ threshold: 10 }, /unknown key "threshold"/],
|
||||
] as Array<[unknown, RegExp]>
|
||||
for (const [config, pattern] of bad) {
|
||||
expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultPruneService content transform', () => {
|
||||
it('measures text code points only and skips content within threshold', () => {
|
||||
const prune = service()
|
||||
const blocks = [
|
||||
{ type: 'text', text: 'a😀b' },
|
||||
{ type: 'reasoning', text: 'not measured' },
|
||||
] satisfies ContentBlock[]
|
||||
expect(prune.measureContent(blocks)).toBe(3)
|
||||
expect(prune.pruneContent(blocks)).toBeNull()
|
||||
expect(codePointLength('a😀b')).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps configured head and tail without splitting surrogate pairs', () => {
|
||||
const prune = service()
|
||||
const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }])
|
||||
expect(result).toEqual([{
|
||||
type: 'text',
|
||||
text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`,
|
||||
}])
|
||||
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
|
||||
expect(result![0]).toMatchObject({ type: 'text' })
|
||||
expect((result![0] as { text: string }).text).not.toContain('\uFFFD')
|
||||
})
|
||||
|
||||
it('preserves non-text blocks and their relative ordering across removed text', () => {
|
||||
const prune = service()
|
||||
const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' }
|
||||
const call: ContentBlock = {
|
||||
type: 'tool-call',
|
||||
id: CallId('nested'),
|
||||
name: 'nested',
|
||||
arguments: '{}',
|
||||
}
|
||||
const result = prune.pruneContent([
|
||||
{ type: 'text', text: 'A'.repeat(40) },
|
||||
reasoning,
|
||||
{ type: 'text', text: 'B'.repeat(30) },
|
||||
call,
|
||||
{ type: 'text', text: 'C'.repeat(30) },
|
||||
])
|
||||
expect(result).toEqual([
|
||||
{ type: 'text', text: `AAAA${PRUNE_MARKER}` },
|
||||
reasoning,
|
||||
call,
|
||||
{ type: 'text', text: 'CCC' },
|
||||
])
|
||||
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('supports zero-sized head and tail while still shrinking', () => {
|
||||
const prune = service({
|
||||
thresholdChars: codePointLength(PRUNE_MARKER),
|
||||
headChars: 0,
|
||||
tailChars: 0,
|
||||
})
|
||||
const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }])
|
||||
expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }])
|
||||
expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultPruneService session transaction', () => {
|
||||
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
|
||||
const session = new Session(SessionId('preserve'))
|
||||
const originalSeq = appendToolStep(session, 1, 'one', [{
|
||||
type: 'text',
|
||||
text: 'x'.repeat(100),
|
||||
}], {
|
||||
isError: true,
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { diff: ['a', 'b'] },
|
||||
futureField: { nested: true },
|
||||
})
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
const result = service().pruneSession(session)
|
||||
expect(result.pruned).toHaveLength(1)
|
||||
expect(result.charsRemoved).toBeGreaterThan(0)
|
||||
const entry = result.pruned[0]!
|
||||
expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 })
|
||||
expect(entry.charsAfter).toBeLessThanOrEqual(50)
|
||||
|
||||
const original = session.events[originalSeq]!
|
||||
const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
|
||||
expect(original).toMatchObject({
|
||||
type: 'tool/result',
|
||||
data: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
|
||||
})
|
||||
expect(replacement).toMatchObject({
|
||||
type: 'tool/result',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('one'),
|
||||
isError: true,
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { diff: ['a', 'b'] },
|
||||
futureField: { nested: true },
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
|
||||
sourceEventSeqs: [originalSeq],
|
||||
})
|
||||
expect(session.surface.nodes).not.toContain(originalSeq)
|
||||
})
|
||||
|
||||
it('prunes multiple results, skips short ones, and converges in one pass', () => {
|
||||
const session = new Session(SessionId('multiple'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
|
||||
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
|
||||
session.append('turn/start', {
|
||||
turn: 4,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const prune = service()
|
||||
const first = prune.pruneSession(session)
|
||||
const second = prune.pruneSession(session)
|
||||
expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')])
|
||||
expect(first.charsRemoved).toBe(
|
||||
first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
|
||||
)
|
||||
expect(second).toEqual({ pruned: [], charsRemoved: 0 })
|
||||
})
|
||||
|
||||
it('replays to the identical pruned model messages', () => {
|
||||
const session = new Session(SessionId('replay'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
service().pruneSession(session)
|
||||
const replay = new Session(session.id, [...session.events])
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
|
||||
})
|
||||
|
||||
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)
|
||||
const prune = new ToolResultPruneService(ctx, SMALL)
|
||||
const session = ctx.sessions.create(SessionId('invariants'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(() => prune.pruneSession(session)).not.toThrow()
|
||||
})
|
||||
})
|
||||
15
packages/compact/compact-tool-result-prune/tsconfig.json
Normal file
15
packages/compact/compact-tool-result-prune/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" }
|
||||
]
|
||||
}
|
||||
@@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
|
||||
1. appends `compact/start` (log-only) — acquires the lock,
|
||||
2. summarizes the range,
|
||||
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
|
||||
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**,
|
||||
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
|
||||
5. appends `compact/end` (log-only) — releases the lock.
|
||||
|
||||
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
|
||||
@@ -90,5 +90,5 @@ No conversation-cache invalidation. A consumer's auxiliary request can reuse onl
|
||||
## 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.
|
||||
- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted.
|
||||
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
|
||||
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// Keep the Loader config under examples so both modes exercise the same deployable
|
||||
// topology: local fixture source plus bare plugins owned by the examples workspace.
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context-driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml',
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context.cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||
child = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
@@ -40,68 +27,25 @@ async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-'))
|
||||
const cwd = workdir
|
||||
return new Promise((resolve, reject) => {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: [configPath],
|
||||
describe('time-context through a real headless cordis.yml', () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'time-context headless smoke',
|
||||
tempDirPrefix: 'time-context-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
exposeInternals: true,
|
||||
env: {
|
||||
TZ: 'Asia/Shanghai',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
env: { TZ: 'Asia/Shanghai' },
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
},
|
||||
})
|
||||
const proc = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let sentSecond = false
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
|
||||
sentSecond = true
|
||||
proc.stdin.end('second\n')
|
||||
}
|
||||
})
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, PROCESS_TIMEOUT_MS)
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve({ stdout, stderr })
|
||||
else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
proc.stdin.write('first\n')
|
||||
})
|
||||
}
|
||||
|
||||
describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
const { stdout, stderr } = await runTwoTurns()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('time-context e2e ready.')
|
||||
expect(stdout).toContain(FIRST_REPLY)
|
||||
expect(stdout).toContain(SECOND_REPLY)
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const contexts = events.filter(event => event.type === 'context/message')
|
||||
@@ -127,5 +71,5 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
|
||||
The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `<system-reminder>` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `<context source="...">` envelope.
|
||||
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
|
||||
|
||||
## State And Refresh
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context.content, {
|
||||
source: update.context.source,
|
||||
envelope: update.context.envelope,
|
||||
meta: update.context.meta,
|
||||
})
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
|
||||
@@ -163,6 +163,12 @@ function buildInstructionText(
|
||||
): string {
|
||||
const marker = markerText(maxBytes, omitted, truncated)
|
||||
const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
|
||||
// Caller-owned framing: the plugin bakes the complete `<system-reminder>`
|
||||
// frame into the message content. The session surface projects context
|
||||
// verbatim and does not wrap it, so any framing must live here in the
|
||||
// producer's content (the pattern a future `meta`-driven renderer would
|
||||
// generalize — see the deferred note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md).
|
||||
return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,8 @@ export interface ReconciledInstructionContext {
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}
|
||||
|
||||
/** Plugin-owned raw context with required replay metadata. */
|
||||
/** Plugin-owned context with required replay metadata. */
|
||||
export interface WorkspaceHookContext extends HookContext {
|
||||
envelope: 'raw'
|
||||
meta: JsonValue
|
||||
}
|
||||
|
||||
@@ -76,7 +75,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
|
||||
...change.digest !== undefined ? { digest: change.digest } : {},
|
||||
}))
|
||||
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
|
||||
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta }
|
||||
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -175,7 +175,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
@@ -204,7 +203,6 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
|
||||
return {
|
||||
content: [{ type: 'text', text: `instructions for ${scope}` }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta: {
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
@@ -219,7 +217,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H
|
||||
lastSeq = agent.session.append('context/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
}, { surfaceOp: 'append' }).seq
|
||||
}
|
||||
@@ -1722,7 +1719,6 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
|
||||
expect(workspaceContextOf(result)?.envelope).toBe('raw')
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
@@ -2527,7 +2523,6 @@ describe('dynamic nested workspace context injection', () => {
|
||||
expect(blocksText(result.content)).toBe('downstream replacement')
|
||||
expect(result.additionalContexts).toHaveLength(2)
|
||||
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
|
||||
expect(workspaceContextOf(result)?.envelope).toBe('raw')
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
@@ -2540,7 +2535,8 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
const agent = stubAgent(root)
|
||||
appendAdditionalContexts(agent, result)
|
||||
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
|
||||
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context')
|
||||
expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain('<context source=')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -2886,7 +2882,6 @@ describe('workspace context pending state', () => {
|
||||
const otherWorkspaceEvent = agent.session.append('context/message', {
|
||||
content: otherContext.content,
|
||||
source: otherContext.source,
|
||||
...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {},
|
||||
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
|
||||
}, { surfaceOp: 'append' })
|
||||
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
|
||||
@@ -2896,7 +2891,6 @@ describe('workspace context pending state', () => {
|
||||
const confirmed = agent.session.append('context/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
}, { surfaceOp: 'append' })
|
||||
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)
|
||||
|
||||
@@ -241,12 +241,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
|
||||
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the write produced.\n */',
|
||||
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsWriteOutcome>',
|
||||
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
|
||||
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the edit produced.\n */',
|
||||
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsEditOutcome>',
|
||||
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -304,6 +304,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandboxPolicy',
|
||||
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
|
||||
methods: [],
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
summary: 'Durable append-only session storage.',
|
||||
@@ -362,7 +367,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session',
|
||||
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`,\n * `parentSession` lineage) as the immutable {@link SessionHeader} (the store\n * fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
|
||||
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
|
||||
},
|
||||
{
|
||||
signature: 'prepare(id?: SessionId, options?: CreateSessionOptions): Session',
|
||||
@@ -522,6 +527,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'toolResultPrune',
|
||||
summary: 'Deterministic head/middle/tail pruning for current tool-result surface nodes.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'measureContent(blocks: readonly ContentBlock[]): number',
|
||||
jsDoc: '/**\n * Measure text content in Unicode code points; non-text blocks cost zero.\n * @param blocks - tool-result content to measure.\n * @returns total Unicode code points across text blocks.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null',
|
||||
jsDoc: '/**\n * Replace an over-budget text middle while retaining rich-block order.\n * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained\n * boundary cannot split a surrogate pair. Grapheme clusters may still split.\n * @param blocks - original tool-result content.\n * @returns pruned content, or `null` when the text is within budget.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'pruneSession(session: Session): PruneResult',
|
||||
jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
summary: 'Tool registry and execution pipeline.',
|
||||
@@ -652,8 +675,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 drained prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent draining its inbox.\n * @param content - the drained 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 */',
|
||||
summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.',
|
||||
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 */',
|
||||
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
|
||||
},
|
||||
{
|
||||
name: 'agent/queued',
|
||||
@@ -672,8 +695,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/request-error',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>',
|
||||
jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param retryAttempt - zero-based number of prior recovery retries.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>',
|
||||
jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Recover a model-request failure after its failed step has closed.',
|
||||
},
|
||||
{
|
||||
@@ -1068,17 +1091,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ContentBlockType',
|
||||
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
|
||||
},
|
||||
{
|
||||
name: 'ContextEnvelope',
|
||||
declaration: 'export type ContextEnvelope = \'context\' | \'raw\';',
|
||||
},
|
||||
{
|
||||
name: 'CreateAgentOptions',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'DiffCallView',
|
||||
@@ -1114,7 +1133,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'FinishReasonMap',
|
||||
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n };\n \'error\': {\n kind: \'error\';\n message: string;\n code?: string;\n };\n}',
|
||||
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'FsDirEntry',
|
||||
@@ -1170,11 +1189,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'HookContext',
|
||||
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}',
|
||||
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InjectOptions',
|
||||
declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}',
|
||||
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'JsonValue',
|
||||
@@ -1184,6 +1203,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'LlmCallConfig',
|
||||
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
|
||||
},
|
||||
{
|
||||
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: 'LlmModelInfo',
|
||||
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
|
||||
@@ -1220,6 +1243,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PromptSection',
|
||||
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProviderRequestId',
|
||||
declaration: 'export type ProviderRequestId = Branded<\'ProviderRequestId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'PrunedEntry',
|
||||
declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PruneResult',
|
||||
declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ReasoningBlock',
|
||||
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
|
||||
@@ -1258,7 +1293,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource; /* …truncated — full shape in source */',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventReadRequest',
|
||||
@@ -1294,7 +1329,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionHeader',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionId',
|
||||
@@ -1566,7 +1601,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 message: string;\n code?: string;\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 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}',
|
||||
},
|
||||
{
|
||||
name: 'TurnTrigger',
|
||||
|
||||
@@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
* A write-through console for one sandbox, tagging every line with the mount
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a mounted listener fires long after the mount call returned, and its output
|
||||
* must land somewhere the user can see — for the stdio demo, the terminal.
|
||||
* must land somewhere the user can see — for a terminal front door, the host terminal.
|
||||
*/
|
||||
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
|
||||
const tag = `[cordis:${id}]`
|
||||
|
||||
@@ -46,7 +46,9 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
@@ -54,7 +56,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 observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` 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`. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. 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.
|
||||
|
||||
@@ -63,6 +65,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
|
||||
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
|
||||
- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
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 { Inbox, type InboxMessage } from './inbox.ts'
|
||||
@@ -80,7 +80,6 @@ export function prepareReactLoopAgent(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the concrete agent's scope context exactly once. Construction and
|
||||
* scope minting are mutually referential (the scope key is the agent), so the
|
||||
@@ -252,7 +251,6 @@ export class ReactLoopAgent implements Agent {
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -291,7 +289,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (turnRecorded) {
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
|
||||
const rendered = renderThrown(error)
|
||||
const rendered = errorChain(error)
|
||||
const err = error instanceof Error ? error : new Error(rendered)
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
|
||||
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
|
||||
@@ -398,7 +396,7 @@ export class ReactLoopAgent implements Agent {
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-step cancellation re-parks without emitting a status transition.
|
||||
// Pre-start cancellation settles queued-work waiters before publishing idle.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
}))
|
||||
}
|
||||
@@ -445,8 +443,3 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an ordinary thrown value for the error event and log. */
|
||||
function renderThrown(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface InboxMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
|
||||
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
*/
|
||||
@@ -54,11 +54,11 @@ export class Inbox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
* Remove the oldest queued message for one turn start.
|
||||
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
dequeueQueued(): InboxMessage | undefined {
|
||||
return this.queuedMessages.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ export class Inbox {
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
|
||||
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queuedMessages.length = 0
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
ResumeAgentOptions,
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -41,15 +41,6 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.FAILED,
|
||||
])
|
||||
|
||||
/** Render an arbitrary thrown value without letting coercion escape containment. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory-level ownership of every preparing or live transaction. */
|
||||
class FactoryOwnership {
|
||||
private accepting = true
|
||||
@@ -475,16 +466,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!this.ownership.isActive()) return
|
||||
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`)
|
||||
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
|
||||
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((listenerError: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`)
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
|
||||
})
|
||||
} catch (listenerError: unknown) {
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`)
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,12 +611,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
transaction.assertActive()
|
||||
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: loaded.events,
|
||||
meta: {
|
||||
createdAt: loaded.meta.createdAt,
|
||||
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
|
||||
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
|
||||
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
|
||||
},
|
||||
meta: loaded.meta,
|
||||
})
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, 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'
|
||||
@@ -28,24 +28,29 @@ function toError(error: unknown): RequestError {
|
||||
|
||||
/** Distinguishes final model-request failures from failures in later step processing. */
|
||||
class TerminalModelRequestFailure extends Error {
|
||||
constructor(readonly requestError: RequestError) {
|
||||
super(requestError.message, { cause: requestError })
|
||||
constructor(
|
||||
readonly requestError: RequestError,
|
||||
readonly failure: LlmFailure,
|
||||
) {
|
||||
super(failure.message, { cause: requestError })
|
||||
this.name = 'TerminalModelRequestFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
|
||||
function finishError(finish: FinishReason): RequestError | undefined {
|
||||
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error: RequestError = new Error(finish.message)
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error: RequestError = new Error('model stream aborted')
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
const facts = finish.failure
|
||||
const error = new LlmError(facts.message, facts.code, {
|
||||
...facts.status === undefined ? {} : { status: facts.status },
|
||||
...facts.providerRetryAfterMs === undefined
|
||||
? {}
|
||||
: { providerRetryAfterMs: facts.providerRetryAfterMs },
|
||||
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
|
||||
})
|
||||
return { error, failure: error.failure }
|
||||
}
|
||||
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
|
||||
default:
|
||||
@@ -56,9 +61,18 @@ function finishError(finish: FinishReason): RequestError | undefined {
|
||||
/**
|
||||
* Build the `{ message, code? }` part of an error payload, omitting the
|
||||
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
||||
* The durable message renders the full cause chain: `turn/end` is the single
|
||||
* durable record of an in-turn failure, so a wrapper message alone (e.g.
|
||||
* `fetch failed`) would lose the diagnosis the session log exists to keep.
|
||||
*/
|
||||
function errorData(err: RequestError): { message: string; code?: string } {
|
||||
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
}
|
||||
|
||||
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
|
||||
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
|
||||
const message = errorChain(err)
|
||||
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
|
||||
}
|
||||
|
||||
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
|
||||
@@ -91,16 +105,16 @@ export interface LoopHandle {
|
||||
cancelReason(): string
|
||||
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
||||
clearCancel(): void
|
||||
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
|
||||
/** 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. */
|
||||
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||
* current turn without terminating the driver. The caller establishes the
|
||||
* `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* Drive queued messages as independent durable turns until disposal. Plugin
|
||||
* failures end the current turn without terminating the driver. The caller
|
||||
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* orchestration recovers that exact Agent and captures its Session locally.
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
@@ -118,20 +132,35 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs and owns the eventual idle transition.
|
||||
// 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.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// 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.inbox.hasQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
handle.setStatus('running')
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
@@ -151,7 +180,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
@@ -182,16 +211,15 @@ async function runTurn(
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
// Drain before opening the turn, but append only after `turn/start`.
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
// Claim one queued message before opening its turn, but append it only after `turn/start`.
|
||||
const message = handle.inbox.dequeueQueued()
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
||||
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
let requestRetryAttempt = 0
|
||||
let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
@@ -204,10 +232,12 @@ async function runTurn(
|
||||
}
|
||||
|
||||
// Record the durable turn failure once and contain the live error notification.
|
||||
const failTurn = (err: RequestError): void => {
|
||||
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
reason = failure === undefined
|
||||
? { kind: 'error', step, ...errorData(err) }
|
||||
: { kind: 'error', step, failure: durableFailure(err, failure) }
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
@@ -226,56 +256,36 @@ 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 })
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// 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.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
const decision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
// Record the veto durably: `PromptDecision.reason` is the durable record
|
||||
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
||||
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
||||
// blocked, another allowed) does not end `rejected` at all — so without
|
||||
// this append a blocked prompt would vanish from the log whenever any
|
||||
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
||||
// place of the `user/message` this prompt would have become.
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
||||
continue
|
||||
}
|
||||
anyAllowed = true
|
||||
const promptDecision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (promptDecision.kind === 'block') {
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
||||
reason = { kind: 'rejected', reason: promptDecision.reason }
|
||||
} else {
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
const content = promptDecision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// Every `allow.additionalContexts` entry is a separate context/message the
|
||||
// next request also sees. The turn is open, so inject() appends each one
|
||||
// into THIS turn without flattening provenance, framing, or metadata.
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
// into THIS turn without flattening provenance or metadata.
|
||||
for (const context of promptDecision.additionalContexts ?? []) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// A fully blocked batch closes its zero-step turn as rejected.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
}
|
||||
// A blocked prompt closes its zero-step turn as rejected.
|
||||
if (promptDecision.kind === 'block') break
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
@@ -353,14 +363,14 @@ async function runTurn(
|
||||
|
||||
let stepOutcome:
|
||||
| { hadToolCalls: boolean; finish: FinishReason }
|
||||
| { requestError: RequestError }
|
||||
| { requestError: RequestError; failure: LlmFailure }
|
||||
| { error: RequestError }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TerminalModelRequestFailure) {
|
||||
stepOutcome = { requestError: error.requestError }
|
||||
stepOutcome = { requestError: error.requestError, failure: error.failure }
|
||||
} else {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
@@ -383,12 +393,12 @@ async function runTurn(
|
||||
try {
|
||||
recoveryDecision = await events.waterfall(
|
||||
'agent/request-error', turn, step, stepOutcome.requestError,
|
||||
requestRetryAttempt, abort.signal,
|
||||
stepOutcome.failure, requestFailureHistory, abort.signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (recoveryError: unknown) {
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`,
|
||||
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
handle.setAbort(undefined)
|
||||
@@ -404,10 +414,10 @@ async function runTurn(
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
case 'retry':
|
||||
requestRetryAttempt += 1
|
||||
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
|
||||
continue
|
||||
case 'fail':
|
||||
failTurn(stepOutcome.requestError)
|
||||
failTurn(stepOutcome.requestError, stepOutcome.failure)
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
@@ -435,7 +445,7 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
requestRetryAttempt = 0
|
||||
requestFailureHistory = Object.freeze([])
|
||||
|
||||
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
@@ -552,7 +562,7 @@ async function runTurn(
|
||||
} catch (error: unknown) {
|
||||
// The turn is closed, so report the failed flush live rather than append outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
@@ -635,13 +645,14 @@ async function runStep(
|
||||
assembler.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
|
||||
const failure = llmFailureOf(stream, error)
|
||||
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Normalize failure finish chunks into the same path as thrown stream errors.
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw new TerminalModelRequestFailure(stepError)
|
||||
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
|
||||
|
||||
const recordAssistantMessage = (
|
||||
assembledContent: ContentBlock[],
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me')
|
||||
send(agent, 'drop me first')
|
||||
send(agent, 'drop me second')
|
||||
agent.cancel('pre-step')
|
||||
|
||||
// Give the loop a chance to wake and process the cancel.
|
||||
@@ -91,6 +92,35 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('disposal from the running notification drops queued work before turn start', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('dispose-running-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
|
||||
const running = Promise.withResolvers<undefined>()
|
||||
let disposalDone: Promise<void> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'running') return
|
||||
disposalDone = handle.dispose()
|
||||
running.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'drop before claim')
|
||||
await running.promise
|
||||
if (disposalDone === undefined) throw new Error('running listener did not start disposal')
|
||||
await disposalDone
|
||||
await driverDone(agent)
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('x')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -110,7 +140,162 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
|
||||
it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectFirstFlush = true
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || !rejectFirstFlush) return
|
||||
rejectFirstFlush = false
|
||||
throw new Error('first flush failed')
|
||||
})
|
||||
|
||||
const cancelled = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject !== agent || error.message !== 'first flush failed') return
|
||||
// The first hop runs before runLoop resumes from runTurn; the second lands
|
||||
// before its resolved waitForQueued continuation checks cancellation.
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => {
|
||||
agent.cancel('between turns')
|
||||
cancelled.resolve(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'queued tail')
|
||||
await cancelled.promise
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(userTexts(agent)).toEqual(['first'])
|
||||
|
||||
let idleResolved = false
|
||||
void agent.whenIdle().then(() => { idleResolved = true })
|
||||
await Promise.resolve()
|
||||
expect(idleResolved).toBe(true)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'idle steer' }])
|
||||
await idle
|
||||
|
||||
expect(statuses).toEqual(['running', 'idle', 'running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'idle steer'])
|
||||
})
|
||||
|
||||
it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectFirstFlush = true
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || !rejectFirstFlush) return
|
||||
rejectFirstFlush = false
|
||||
throw new Error('first flush failed')
|
||||
})
|
||||
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject !== agent || error.message !== 'first flush failed') return
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => { agent.cancel('between turns') })
|
||||
})
|
||||
})
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
|
||||
send(agent, 'replacement')
|
||||
replacementObservation = agent.whenIdle().then(() => ({
|
||||
status: agent.status,
|
||||
requests: adapter.requests.length,
|
||||
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
|
||||
}))
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'cancelled tail')
|
||||
await replacementRegistered.promise
|
||||
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
|
||||
|
||||
await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 })
|
||||
expect(userTexts(agent)).toEqual(['first', 'replacement'])
|
||||
})
|
||||
|
||||
it('idle-listener cancellation settles its waiter without cancelling later work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
|
||||
send(agent, 'cancelled replacement')
|
||||
replacementObservation = agent.whenIdle().then(() => ({
|
||||
status: agent.status,
|
||||
requests: adapter.requests.length,
|
||||
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
|
||||
}))
|
||||
agent.cancel('idle listener')
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await replacementRegistered.promise
|
||||
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
|
||||
|
||||
await expect(Promise.race([
|
||||
replacementObservation,
|
||||
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
|
||||
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'later')
|
||||
await idle
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'later'])
|
||||
})
|
||||
|
||||
it('replacement work queued after idle-listener cancellation still runs', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementIdle: Promise<void> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
|
||||
send(agent, 'cancelled replacement')
|
||||
agent.cancel('idle listener')
|
||||
send(agent, 'surviving replacement')
|
||||
replacementIdle = agent.whenIdle()
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await replacementRegistered.promise
|
||||
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
|
||||
await replacementIdle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -121,10 +306,14 @@ describe('Agent.cancel()', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
send(agent, 'queued tail')
|
||||
agent.cancel('mid-step')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
|
||||
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 () => {
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('config-driven session id', () => {
|
||||
it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
|
||||
const exact = await makeCoreContext()
|
||||
await exact.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }],
|
||||
})
|
||||
expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact')
|
||||
expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact')
|
||||
await exact.fiber.dispose()
|
||||
|
||||
const conflicting = await makeCoreContext()
|
||||
@@ -89,13 +89,13 @@ describe('config-driven session id', () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
|
||||
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
let first: Agent | undefined
|
||||
for (let i = 0; i < 50 && first === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
first = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
first = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
@@ -106,14 +106,14 @@ describe('config-driven session id', () => {
|
||||
let second: Agent | undefined
|
||||
for (let i = 0; i < 50 && second === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
second = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
second = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, second!)
|
||||
await ctx.sessions.flush(second!.session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload'))
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
|
||||
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
await secondLoop.dispose()
|
||||
@@ -125,7 +125,7 @@ describe('config-driven session id', () => {
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('stdio-exact-overlap')
|
||||
const sessionId = SessionId('config-exact-overlap')
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
@@ -169,7 +169,7 @@ describe('config-driven session id', () => {
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('stdio-exact-cancel')
|
||||
const sessionId = SessionId('config-exact-cancel')
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
@@ -213,20 +213,20 @@ describe('config-driven session id', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed',
|
||||
'config-driven restore of "config-exact-failure" failed: persistence index failed',
|
||||
))
|
||||
expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }])
|
||||
expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: Error: failure observer failed',
|
||||
'agent "main": config-start-failed listener threw: failure observer failed',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: Error: async failure observer failed',
|
||||
'agent "main": config-start-failed listener rejected: async failure observer failed',
|
||||
)
|
||||
expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -251,18 +251,18 @@ describe('config-driven session id', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => failures).toEqual([unrenderable])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: <unrenderable thrown value>',
|
||||
'agent "main": config-driven restore of "config-exact-unrenderable" failed: <unrenderable value>',
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: <unrenderable thrown value>',
|
||||
'agent "main": config-start-failed listener threw: <unrenderable value>',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: <unrenderable thrown value>',
|
||||
'agent "main": config-start-failed listener rejected: <unrenderable value>',
|
||||
)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -281,7 +281,7 @@ describe('config-driven session id', () => {
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
|
||||
const loop = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = loop.dispose().then(() => { disposed = true })
|
||||
@@ -291,7 +291,7 @@ describe('config-driven session id', () => {
|
||||
if (outcome === 'resolve') listing.resolve([])
|
||||
else listing.reject(new Error('startup cancelled by teardown'))
|
||||
await disposal
|
||||
expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
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, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
@@ -640,15 +640,20 @@ describe('plugin exceptions are contained', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
|
||||
it('a rejecting first-turn flush settles before the queued tail starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectedOnce = false
|
||||
ctx.on('session/flush', async () => {
|
||||
if (!rejectedOnce) {
|
||||
rejectedOnce = true
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
throw new Error('disk full')
|
||||
}
|
||||
})
|
||||
@@ -656,18 +661,25 @@ describe('plugin exceptions are contained', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
await firstFlush.promise
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposed status is part of the agent/status contract', () => {
|
||||
it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
|
||||
it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -683,11 +695,19 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
send(agent, 'queued tail')
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(statuses).toEqual(['running', 'disposed'])
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.flatMap(event => event.data.content)
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
expect(messages).toEqual(['go'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
|
||||
@@ -933,8 +953,15 @@ describe('discriminated SessionEvent narrows without casts', () => {
|
||||
describe('a finish-error stream chunk ends the turn as error, not completed', () => {
|
||||
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
|
||||
// A finish-error chunk must not produce a completed assistant turn.
|
||||
const failure = {
|
||||
message: 'provider 401',
|
||||
code: 'AUTH',
|
||||
status: 401,
|
||||
providerRetryAfterMs: 2_000,
|
||||
requestId: ProviderRequestId('finish-request-1'),
|
||||
}
|
||||
const errorStream: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
|
||||
{ type: 'finish', reason: { kind: 'error', failure } },
|
||||
]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -946,20 +973,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }])
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
|
||||
|
||||
const events = [...agent.session.events]
|
||||
// The durable failure lives on turn/end.reason (with the failing step), not
|
||||
// a standalone error event.
|
||||
const turnEnd = events.find(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' })
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
|
||||
// A failed step must not synthesize an assistant message.
|
||||
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
|
||||
const abortedStream: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'aborted' } },
|
||||
{ type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } },
|
||||
]
|
||||
const adapter = new MockAdapter([abortedStream])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -971,13 +998,13 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }])
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
|
||||
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles a finish error without a code (code key omitted)', async () => {
|
||||
const errorStream: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'error', message: 'codeless failure' } },
|
||||
{ type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } },
|
||||
]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -989,7 +1016,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }])
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1109,7 +1136,7 @@ describe('turn and step boundary recovery', () => {
|
||||
})
|
||||
|
||||
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
|
||||
@@ -1139,7 +1166,7 @@ describe('turn and step boundary recovery', () => {
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
|
||||
kind: 'error',
|
||||
message: 'provider failed',
|
||||
failure: { message: 'provider failed', code: 'UNKNOWN' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1175,7 +1202,7 @@ describe('turn and step boundary recovery', () => {
|
||||
|
||||
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
|
||||
// Listener failure cannot interrupt error finalization or the next turn.
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
|
||||
@@ -1191,7 +1218,11 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(c.turnStart).toBe(1)
|
||||
expect(c.turnEnd).toBe(1)
|
||||
expect(c.stepStart).toBe(c.stepEnd)
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' })
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: { message: 'provider 500', code: 'SERVER' },
|
||||
})
|
||||
|
||||
// loop survives: a second turn runs to completion (invariants oracle would
|
||||
// throw on its turn/start if turn 1 had been left open).
|
||||
@@ -1336,7 +1367,7 @@ describe('turn and step boundary recovery', () => {
|
||||
|
||||
it('a throwing step/end observer cannot interrupt error finalization', async () => {
|
||||
// Observer failure after step/end commit cannot interrupt turn finalization.
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -146,12 +146,22 @@ describe('toError normalization', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
send(agent, 'fails before turn start')
|
||||
send(agent, 'survives as the next item')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const starts = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const ends = agent.session.events.filter(event => event.type === 'turn/end')
|
||||
const messages = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'survives as the next item' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
@@ -177,7 +187,9 @@ describe('toError normalization', () => {
|
||||
// String() of { code: 500 } is '[object Object]'
|
||||
expect(errors[0]!.message).toBe('[object Object]')
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
|
||||
.toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +220,8 @@ describe('coded error data emission', () => {
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd).toBeDefined()
|
||||
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
|
||||
expect(turnEnd.data.reason.code).toBe('RATE_LIMIT')
|
||||
expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)
|
||||
.toBe('RATE_LIMIT')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,17 +8,17 @@ function resolverPair() {
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('enqueues and drains queued messages in FIFO order', () => {
|
||||
it('dequeues one queued message at a time in FIFO order', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
|
||||
const drained = inbox.drainQueued()
|
||||
expect(drained).toHaveLength(2)
|
||||
expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
|
||||
@@ -99,7 +99,6 @@ describe('agent/prompt-submit', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}],
|
||||
}))
|
||||
@@ -113,7 +112,6 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
@@ -179,9 +177,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
|
||||
})
|
||||
|
||||
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
|
||||
// Blocking one prompt in a mixed batch must persist its reason even though
|
||||
// the allowed prompt keeps the turn from ending rejected.
|
||||
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -194,13 +190,13 @@ describe('agent/prompt-submit', () => {
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
// both sends land before the loop drains → one batched turn
|
||||
// Both sends land before the driver wakes, but each remains its own turn.
|
||||
send(agent, 'secret')
|
||||
send(agent, 'safe')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// the allowed prompt became a user/message and drove exactly one model call
|
||||
// The allowed prompt became a user/message and drove exactly one model call.
|
||||
const userMsgs = log.filter(e => e.type === 'user/message')
|
||||
expect(userMsgs).toHaveLength(1)
|
||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||
@@ -212,12 +208,14 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'secret' }],
|
||||
reason: 'policy: no secrets',
|
||||
})
|
||||
// the turn did NOT reject — a sibling was allowed — so the boundary reason
|
||||
// alone would not have preserved the block
|
||||
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'rejected', reason: 'policy: no secrets' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||
it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -228,20 +226,31 @@ describe('agent/prompt-submit', () => {
|
||||
return { kind: 'allow' as const }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// turn balanced
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
// loop survives: a second prompt runs normally
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
await idle
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// The failed prompt forms one balanced error turn; the adjacent prompt forms
|
||||
// the following normal turn without an intermediate idle transition.
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'error', step: 0, message: 'prompt hook broke' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -556,7 +565,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'p' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
}))
|
||||
@@ -580,7 +588,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
@@ -591,7 +598,7 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } })
|
||||
return [{ type: 'text', text: 'outer result' }]
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -354,14 +354,24 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.steer([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'first idle steer' }])
|
||||
agent.steer([{ type: 'text', text: 'second idle steer' }])
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||
@@ -385,10 +395,10 @@ describe('agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
const flat = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(flat).toContain('file changed: a.ts')
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
expect(flat).not.toContain('<context source=')
|
||||
})
|
||||
|
||||
it('inject() can persist raw structured context without the generic context envelope', async () => {
|
||||
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
|
||||
@@ -401,14 +411,13 @@ describe('agent loop', () => {
|
||||
|
||||
agent.inject([{ type: 'text', text }], {
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta })
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
@@ -432,7 +441,6 @@ describe('agent loop', () => {
|
||||
const first = { type: 'text' as const, text: 'mid-turn notice' }
|
||||
agent.inject([first], {
|
||||
source: { kind: 'plugin', plugin: 'x' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
first.text = 'mutated after inject'
|
||||
@@ -458,7 +466,6 @@ describe('agent loop', () => {
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
@@ -925,7 +932,149 @@ describe('agent loop', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
send(agent, 'second message')
|
||||
|
||||
await firstFlush.promise
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(flushes).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
await firstFlush.promise
|
||||
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'queued listener message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves independent turn sources across an adjacent microtask send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'user message' }])
|
||||
await Promise.resolve()
|
||||
agent.send(
|
||||
[{ type: 'text', text: 'plugin message' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.map(event => event.data.trigger)
|
||||
const sources = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.source)
|
||||
expect(triggers).toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
||||
])
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a session-listener send after dequeue in the following turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -948,6 +1097,37 @@ describe('agent loop', () => {
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('keeps a model-adapter callback send in the following turn', async () => {
|
||||
const agentRef: { current?: Agent } = {}
|
||||
const adapter = new MockAdapter([
|
||||
() => {
|
||||
const agent = agentRef.current
|
||||
if (agent === undefined) throw new Error('model callback ran before agent setup')
|
||||
send(agent, 'model callback message')
|
||||
return textResponse('first')
|
||||
},
|
||||
textResponse('second'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agentRef.current = agent
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'model callback message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||
|
||||
@@ -81,6 +81,21 @@ function turnNumbers(agent: Agent): number[] {
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
function turnEndNumbers(agent: Agent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/end')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
function userMessageCountsByTurn(agent: Agent): number[] {
|
||||
const counts: number[] = []
|
||||
for (const event of agent.session.events) {
|
||||
if (event.type === 'turn/start') counts.push(0)
|
||||
if (event.type === 'user/message') counts[counts.length - 1]! += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
|
||||
function assertLegalStatusTrace(trace: string[]): void {
|
||||
for (let i = 1; i < trace.length; i++) {
|
||||
@@ -90,7 +105,7 @@ function assertLegalStatusTrace(trace: string[]): void {
|
||||
}
|
||||
|
||||
describe('agent loop scheduling properties', () => {
|
||||
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
|
||||
it('a synchronous burst gives every message its own strictly increasing turn', async () => {
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
|
||||
async (texts) => {
|
||||
@@ -105,8 +120,11 @@ describe('agent loop scheduling properties', () => {
|
||||
|
||||
// No message lost: every send appears as a user/message, in order.
|
||||
expect(userMessageTexts(agent)).toEqual(texts)
|
||||
// A synchronous burst batches into exactly one turn.
|
||||
expect(turnNumbers(agent)).toEqual([1])
|
||||
// This failure-free fixture maps every item to an independent turn.
|
||||
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1))
|
||||
expect(trace).toEqual(['running', 'idle'])
|
||||
assertLegalStatusTrace(trace)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -137,9 +155,9 @@ describe('agent loop scheduling properties', () => {
|
||||
), { numRuns: 20, timeout: 2000 })
|
||||
})
|
||||
|
||||
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
|
||||
// Each step is a (text, settle?) pair: settle=true awaits idle before the
|
||||
// next send (own turn); settle=false sends in the same tick (batches).
|
||||
it('mixed settled and same-tick sends preserve one turn per message', async () => {
|
||||
// Each step optionally waits for idle before the next send; that scheduling
|
||||
// choice must not change the ordinary message-to-turn mapping.
|
||||
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
|
||||
@@ -158,14 +176,13 @@ describe('agent loop scheduling properties', () => {
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
// No message lost or reordered, regardless of batching.
|
||||
// No message is lost or reordered, regardless of driver timing.
|
||||
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
|
||||
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
|
||||
// Every item forms one FIFO-ordered turn containing only that message.
|
||||
const turns = turnNumbers(agent)
|
||||
expect(turns).toEqual(turns.map((_, i) => i + 1))
|
||||
// Every message landed in some turn; turns never exceed messages.
|
||||
expect(turns.length).toBeLessThanOrEqual(steps.length)
|
||||
expect(turns.length).toBeGreaterThanOrEqual(1)
|
||||
expect(turns).toEqual(steps.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(turns)
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Context } from 'cordis'
|
||||
import LlmService, {
|
||||
CallId,
|
||||
CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
HarnessError,
|
||||
LlmAdapter,
|
||||
LlmError,
|
||||
ProviderRequestId,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
@@ -258,16 +260,17 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
|
||||
it.each([
|
||||
['thrown', contextError()],
|
||||
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
|
||||
['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] satisfies StreamChunk[]],
|
||||
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
|
||||
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' })
|
||||
const attempts: number[] = []
|
||||
ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => {
|
||||
ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => {
|
||||
expect(subject).toBe(agent)
|
||||
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
||||
attempts.push(attempt)
|
||||
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
attempts.push(history.length)
|
||||
subject.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
|
||||
source: { kind: 'plugin', plugin: 'test-recovery' },
|
||||
@@ -295,7 +298,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
install(ctx)
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
@@ -326,7 +329,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
@@ -359,7 +362,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
}
|
||||
const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
@@ -387,7 +390,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
}
|
||||
const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
@@ -406,7 +409,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const ctx = await harness(makeAdapter(original))
|
||||
const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
|
||||
let seen: Error | undefined
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
|
||||
seen = error
|
||||
return next()
|
||||
})
|
||||
@@ -417,12 +420,90 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
expect(seen).toBe(original)
|
||||
})
|
||||
|
||||
it('keeps an adapter error with a hostile message accessor on the recovery path', async () => {
|
||||
const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', {
|
||||
get() { throw new Error('SDK message accessor trap') },
|
||||
})
|
||||
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
|
||||
const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' })
|
||||
let seenError: Error | undefined
|
||||
let seenFailure: LlmFailure | undefined
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => {
|
||||
seenError = error
|
||||
seenFailure = failure
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seenError).toBe(original)
|
||||
expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' })
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => {
|
||||
const original = new LlmError('provider busy', 'RATE_LIMIT', {
|
||||
cause: new Error('upstream connection reset'),
|
||||
status: 429,
|
||||
providerRetryAfterMs: 2_000,
|
||||
requestId: ProviderRequestId('req-9'),
|
||||
})
|
||||
Object.freeze(original)
|
||||
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
|
||||
const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' })
|
||||
let seenError: Error | undefined
|
||||
let seenFailure: LlmFailure | undefined
|
||||
let seenHistory: readonly LlmFailure[] | undefined
|
||||
ctx.on('agent/request-error', async (
|
||||
_agent, _turn, _step, error, failure, history, _signal, next,
|
||||
) => {
|
||||
seenError = error
|
||||
seenFailure = failure
|
||||
seenHistory = history
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seenError).toBe(original)
|
||||
expect(seenFailure).toEqual({
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 2_000,
|
||||
requestId: ProviderRequestId('req-9'),
|
||||
})
|
||||
expect(seenHistory).toEqual([])
|
||||
expect(Object.isFrozen(seenHistory)).toBe(true)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: {
|
||||
message: 'provider busy: upstream connection reset',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 2_000,
|
||||
requestId: ProviderRequestId('req-9'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
|
||||
for (const scenario of ['iterator', 'no-adapter'] as const) {
|
||||
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
|
||||
const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' })
|
||||
let seen = ''
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
|
||||
seen = error.code ?? ''
|
||||
return next()
|
||||
})
|
||||
@@ -436,14 +517,17 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
|
||||
const cappedCtx = await harness(capped)
|
||||
const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' })
|
||||
const cappedAttempts: number[] = []
|
||||
cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => {
|
||||
cappedAttempts.push(attempt)
|
||||
return attempt < 1 ? { action: 'retry' } : next()
|
||||
const cappedHistories: string[][] = []
|
||||
cappedCtx.on('agent/request-error', async (
|
||||
_agent, _turn, _step, _error, _failure, history, _signal, next,
|
||||
) => {
|
||||
const codes = history.map(entry => entry.code)
|
||||
cappedHistories.push(codes)
|
||||
return codes.length < 1 ? { action: 'retry' } : next()
|
||||
})
|
||||
send(cappedAgent)
|
||||
await waitForIdle(cappedCtx, cappedAgent)
|
||||
expect(cappedAttempts).toEqual([0, 1])
|
||||
expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]])
|
||||
|
||||
const reset = new FailureScriptAdapter([
|
||||
contextError('first overflow'),
|
||||
@@ -458,14 +542,16 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
async execute() { return [{ type: 'text', text: 'worked' }] },
|
||||
}))
|
||||
const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
|
||||
const resetAttempts: { step: number; attempt: number }[] = []
|
||||
resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => {
|
||||
resetAttempts.push({ step, attempt })
|
||||
return resetAttempts.length === 1 ? { action: 'retry' } : next()
|
||||
const resetHistories: { step: number; codes: string[] }[] = []
|
||||
resetCtx.on('agent/request-error', async (
|
||||
_agent, _turn, step, _error, _failure, history, _signal, next,
|
||||
) => {
|
||||
resetHistories.push({ step, codes: history.map(entry => entry.code) })
|
||||
return resetHistories.length === 1 ? { action: 'retry' } : next()
|
||||
})
|
||||
send(resetAgent)
|
||||
await waitForIdle(resetCtx, resetAgent)
|
||||
expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }])
|
||||
expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }])
|
||||
})
|
||||
|
||||
it('preserves the original provider error when recovery throws', async () => {
|
||||
@@ -479,7 +565,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
|
||||
data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -489,7 +575,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' })
|
||||
let entered!: () => void
|
||||
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => {
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => {
|
||||
entered()
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
|
||||
@@ -411,7 +411,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
@@ -423,7 +423,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const forked = ctx1.sessions.create(SessionId('forked-sess'), {
|
||||
seed,
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
|
||||
})
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -447,6 +447,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
expect(a2.session.header.seedLength).toBe(seed.length)
|
||||
// The recursion budget survives resume — a dropped depth would let a
|
||||
// resumed child delegate as if it were top-level.
|
||||
expect(a2.session.header.delegationDepth).toBe(1)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ 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: a retry opens a new numbered step after the failed step closes. `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. 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. `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. 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).
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
|
||||
`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.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
@@ -54,13 +54,15 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle. 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).
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `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.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. 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: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
|
||||
- `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`
|
||||
|
||||
`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`.
|
||||
|
||||
@@ -46,15 +46,21 @@ export interface CreateAgentOptions {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, and the `seedLength` seed boundary. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength` fields of
|
||||
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
|
||||
* recursion budget. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it). This is durable session data,
|
||||
* so the session boundary validates and snapshots it before asynchronous
|
||||
* setup begins.
|
||||
*/
|
||||
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
* primitive). When present, the factory creates the session with this event
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -32,17 +32,15 @@ export interface SendOptions {
|
||||
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
export interface InjectOptions extends SendOptions {
|
||||
/** Keep the canonical context tag, or send caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
|
||||
* throw).
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
|
||||
* transition leaves it, and `send`/`steer`/`inject` throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
@@ -50,16 +48,15 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/** Keep the canonical context tag, or use caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt and each
|
||||
* `additionalContexts` entry becomes a separate context message. `block` records a
|
||||
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
|
||||
* `additionalContexts` entry becomes a separate context message. `block`
|
||||
* records a durable `prompt/blocked` and ends the claimed prompt's zero-step
|
||||
* turn as rejected.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
@@ -97,15 +94,20 @@ export interface Agent {
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue detached, frozen lossless-JSON input; starts a turn when idle.
|
||||
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
|
||||
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
|
||||
* that turn's checkpoint.
|
||||
* Invalid input throws synchronously before notification or enqueue.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Steer a running turn: content is injected between steps of the current
|
||||
* turn. Uses the same owned-value and synchronous-validation boundary as
|
||||
* {@link send}; when idle, behaves exactly like that method.
|
||||
* Submit steering while the agent is `running`. An open turn records it at
|
||||
* the next steering checkpoint before a request or continuation decision;
|
||||
* policy may stop before another step. After turn close and its checkpoint,
|
||||
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
|
||||
* cancellation, or disposal may discard it. Uses the same synchronous
|
||||
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
@@ -119,10 +121,11 @@ export interface Agent {
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void
|
||||
|
||||
/**
|
||||
* Clear queued and steering work, including work waiting to start, and abort
|
||||
* the active step. The supplied 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.
|
||||
* Clear all queued and steering work, including items waiting to start, and
|
||||
* abort the active step. The supplied 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.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
|
||||
@@ -203,10 +206,10 @@ 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 drained prompt before it becomes a user
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default.
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @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.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
@@ -270,12 +273,13 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the failed step number.
|
||||
* @param error - the original model-request failure.
|
||||
* @param retryAttempt - zero-based number of prior recovery retries.
|
||||
* @param failure - serializable facts normalized at the final adapter boundary.
|
||||
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
|
||||
/**
|
||||
* Override whether the turn continues. The default continues after tool
|
||||
* calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
|
||||
@@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
@@ -32,13 +32,13 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
@@ -49,22 +49,22 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
|
||||
@@ -73,13 +73,13 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
|
||||
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -80,21 +80,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render injected context as tagged synthetic user-role content, keeping the
|
||||
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
|
||||
* belong in the adapter.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
const close = `</${tag}>`
|
||||
return [
|
||||
{ type: 'text', text: open },
|
||||
...content,
|
||||
{ type: 'text', text: close },
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -128,6 +113,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
if (record.delegationDepth !== undefined
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -228,22 +217,6 @@ interface SessionEntry {
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* Render one context contribution exactly as it will appear in model history.
|
||||
* @param content - content blocks supplied by the context producer.
|
||||
* @param source - attribution used by the canonical context envelope.
|
||||
* @param envelope - canonical tagged framing or caller-owned raw framing.
|
||||
* @returns a detached block list ready for the derived model transcript.
|
||||
*/
|
||||
export function renderContextContent(
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
envelope: ContextEnvelope = 'context',
|
||||
): ContentBlock[] {
|
||||
const cloned = structuredClone(content)
|
||||
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -509,7 +482,18 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// Injected context and mid-turn steering project identically to a user
|
||||
// prompt: content verbatim, in user role. context's `source`/`meta` and
|
||||
// steering's `turn` are log-only and do not reach the model. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
// verbatim pass-through. See the deferred design note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
|
||||
case 'user/message':
|
||||
case 'context/message':
|
||||
case 'steering/message': {
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
@@ -526,14 +510,6 @@ export class Session {
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source, envelope } = event.data
|
||||
return { role: 'user', content: renderContextContent(content, source, envelope) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
// no message. Merge-extensible union: no assertNever here.
|
||||
@@ -586,9 +562,9 @@ export class SessionStore extends Service {
|
||||
* Create a session owned by the calling fiber: disposing that fiber stops
|
||||
* event notification and removes the session from the store. `options.seed`
|
||||
* populates the session with a copy of those events (replay/fork);
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`,
|
||||
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
|
||||
* and parent lineage, and delegation depth) as the immutable
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
@@ -650,6 +626,7 @@ export class SessionStore extends Service {
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -187,11 +188,37 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
): void {
|
||||
if (event.type !== 'tool/result') return
|
||||
if (shadowedSeqs.length !== 1) {
|
||||
throw new Error('tool/result surface replacement must rewrite exactly one current node')
|
||||
}
|
||||
for (const originalSeq of shadowedSeqs) {
|
||||
const original = events[originalSeq]
|
||||
if (original?.type !== 'tool/result') {
|
||||
throw new Error('tool/result surface replacement must target a current tool/result')
|
||||
}
|
||||
const originalRest = { ...original.data } as Record<string, unknown>
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
@@ -204,6 +231,7 @@ function planSurfaceEvent(
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
@@ -218,8 +246,9 @@ function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -239,13 +268,13 @@ function applySurfaceEvent(
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns detached current sequences and replacement history.
|
||||
* @throws when an event violates surface metadata, provenance, or range rules.
|
||||
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
const replacement = applySurfaceEvent(state, event, index, events)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
planSurfaceEvent(this._state, event, this.log.length, this.log)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!, i)
|
||||
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
|
||||
export type ContextEnvelope = 'context' | 'raw'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
@@ -50,6 +47,12 @@ export interface SessionHeader {
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
/**
|
||||
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
|
||||
* for a subagent child. Persisted so a recursion budget survives restart and
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,7 @@ export interface CreateSessionOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,15 +106,19 @@ export interface TurnEndReasonMap {
|
||||
* The turn failed: a step threw or the model reported a failure. `step` is the
|
||||
* step number the failure occurred on (the operational error's location — the
|
||||
* single durable record of an in-turn failure; live diagnostics also fire via
|
||||
* `agent/error`). `code` is the error's code when one was attached.
|
||||
* `agent/error`). Final model-request failures retain their normalized facts
|
||||
* as one `failure`; other turn failures retain their live Error message/code.
|
||||
*/
|
||||
error: { kind: 'error'; step: number; message: string; code?: string }
|
||||
error: { kind: 'error'; step: number } & (
|
||||
| { failure: LlmFailure; message?: never; code?: never }
|
||||
| { message: string; code?: string; failure?: never }
|
||||
)
|
||||
disposed: { kind: 'disposed' }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
* Policy blocked the turn's claimed prompt before the first step. The
|
||||
* zero-step turn still records a balanced durable boundary and veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
@@ -179,40 +187,44 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* Opens turn `turn`. `trigger` records what started it — one claimed queued
|
||||
* message or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
|
||||
* own the complete model-facing frame; `meta` is durable JSON state omitted
|
||||
* from the model projection.
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
envelope?: ContextEnvelope
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('renders context and steering messages as tagged synthetic user content', () => {
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
@@ -62,12 +62,12 @@ describe('Session', () => {
|
||||
|
||||
const [contextMessage, steeringMessage] = session.deriveMessages()
|
||||
expect(contextMessage!.role).toBe('user')
|
||||
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
|
||||
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
|
||||
expect(steeringMessage!.role).toBe('user')
|
||||
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
|
||||
})
|
||||
|
||||
it('renders raw context without a generic envelope while preserving structured metadata', () => {
|
||||
it('keeps context meta durable in the event while hiding it from the projection', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
@@ -77,7 +77,6 @@ describe('Session', () => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -882,6 +881,19 @@ describe('SessionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches delegationDepth from meta to the header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('delegated-child'), {
|
||||
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
|
||||
})
|
||||
expect(session.header).toMatchObject({
|
||||
id: 'delegated-child',
|
||||
parentSession: 'parent',
|
||||
delegationDepth: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-JSON and invalid scalar session metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -893,6 +905,9 @@ describe('SessionStore', () => {
|
||||
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
]
|
||||
|
||||
for (const [index, { meta, error }] of cases.entries()) {
|
||||
|
||||
@@ -369,8 +369,8 @@ describe('deriveMessages with surface', () => {
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
|
||||
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
@@ -112,7 +112,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
@@ -262,8 +262,8 @@ export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
|
||||
export interface ToolRunContext extends ToolExecution {
|
||||
/**
|
||||
* Defer one nested-dispatch context until this tool's final result reaches
|
||||
* the agent loop. Contexts retain their individual source, envelope, and
|
||||
* metadata and are emitted in call order.
|
||||
* the agent loop. Contexts retain their individual source and metadata and
|
||||
* are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
}
|
||||
@@ -1095,14 +1095,19 @@ export class ToolRegistry extends Service {
|
||||
// Freeze the registry's live object before observers receive its readonly
|
||||
// WeakMap-keyable view.
|
||||
Object.freeze(exec)
|
||||
const { name: toolName, callId } = exec
|
||||
const reportFailure = (error: unknown): void => {
|
||||
this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
}
|
||||
const callbacks = this.ctx.events.dispatch('emit', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
||||
])
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(exec, result)
|
||||
const returned: unknown = callback(exec, result)
|
||||
void Promise.resolve(returned).catch(reportFailure)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
reportFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +489,6 @@ describe('the run_code dispatch bridge', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin' as const, plugin: 'test' },
|
||||
envelope: 'raw' as const,
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
})
|
||||
@@ -507,13 +506,11 @@ describe('the run_code dispatch bridge', () => {
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:1' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:1' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:2' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:2' },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -49,6 +49,19 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
})
|
||||
|
||||
it('harvests search tools without depending on the generator process PATH', async () => {
|
||||
const oldPath = process.env.PATH
|
||||
try {
|
||||
process.env.PATH = ''
|
||||
const catalog = await collectToolCatalog()
|
||||
const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
|
||||
expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
} finally {
|
||||
if (oldPath === undefined) delete process.env.PATH
|
||||
else process.env.PATH = oldPath
|
||||
}
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
|
||||
// agents surface this one package as both `subagent` and `subagent_fork`.
|
||||
|
||||
@@ -591,13 +591,18 @@ describe('scoped execution dispatch', () => {
|
||||
ctx.on('tools/result', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never)
|
||||
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
|
||||
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||
await Promise.resolve()
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['emit'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
expect(warn.mock.calls.map(call => String(call[0]))).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('<unprintable thrown value>'),
|
||||
expect.stringContaining('async observer failure'),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -424,7 +424,7 @@ describe('ToolRegistry', () => {
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
@@ -458,7 +458,6 @@ describe('ToolRegistry', () => {
|
||||
{ kind: 'plugin', plugin: 'post' },
|
||||
])
|
||||
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
|
||||
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
|
||||
})
|
||||
|
||||
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
||||
|
||||
@@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
|
||||
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
|
||||
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
|
||||
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
|
||||
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-tui-demo`](../tui-demo/README.md): both consume the same spine, but ACP creates sessions from its client and reserves stdout for its wire protocol.
|
||||
|
||||
## What it bakes in — and what it deliberately omits
|
||||
|
||||
@@ -19,7 +19,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
|
||||
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
|
||||
|
||||
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.)
|
||||
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -32,12 +32,15 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy.
|
||||
|
||||
## The bin
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import * as acp from '@deepseek-ai/dsh-acp'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'acp-demo'
|
||||
@@ -47,6 +50,8 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
@@ -55,6 +60,8 @@ export interface Config {
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Bounded transient model-request retry policy forwarded through agent-core. */
|
||||
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
|
||||
}
|
||||
|
||||
// Each front door owns a complete, directly readable config schema; extracting
|
||||
@@ -72,10 +79,12 @@ export const Config: z<Config> = z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
llmRetry: agentCore.LlmRetryConfigSchema,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -89,6 +98,9 @@ export const Config: z<Config> = z.object({
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(acp, { provider: config.provider, model: config.model })
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import * as acpAgent from '../src/index.ts'
|
||||
/**
|
||||
* In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition:
|
||||
* mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP
|
||||
* bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
|
||||
* Loader-only plugin (no hmr), so it mounts in a plain Context.
|
||||
* bridge in one `ctx.plugin`. It loads no Loader-only plugin (no hmr), so it
|
||||
* mounts in a plain Context.
|
||||
*
|
||||
* The REAL Loader-path guard (export shape via `unwrapExports`, the headline
|
||||
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
|
||||
@@ -70,10 +70,19 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
|
||||
describe('dsh-acp-demo composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-acp-demo-test',
|
||||
persistenceCompression: 'none',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -15,21 +15,24 @@ import {
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
* require a valid initialize response. This catches built-only settle races and stdout protocol
|
||||
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
|
||||
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
|
||||
* complete a mock-backed turn. This catches built-only settle races, stdout protocol leaks, and
|
||||
* published persistence behavior that the tsx source-path smoke cannot. It skips before build;
|
||||
* `--expose-internals` enables Cordis bare-plugin loading.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
|
||||
const decompress = promisify(zstdDecompress)
|
||||
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
@@ -73,18 +76,31 @@ async function makeConsumer(): Promise<string> {
|
||||
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
|
||||
await link(dirname(resolved), dep, nm)
|
||||
}
|
||||
await writeFile(join(dir, 'mock-llm.mjs'), [
|
||||
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
|
||||
'class Mock extends LlmAdapter {',
|
||||
' async * stream() {',
|
||||
" yield { type: 'block-start', index: 0, blockType: 'text' }",
|
||||
" yield { type: 'text-delta', index: 0, text: 'ACP BUILT OK' }",
|
||||
" yield { type: 'block-end', index: 0, block: { type: 'text', text: 'ACP BUILT OK' } }",
|
||||
" yield { type: 'finish', reason: { kind: 'stop' } }",
|
||||
' }',
|
||||
'}',
|
||||
"export const name = 'built-acp-mock'",
|
||||
"export const inject = ['llm']",
|
||||
"export function apply(ctx) { ctx.llm.registerAdapter(['built-acp-mock'], new Mock()) }",
|
||||
'',
|
||||
].join('\n'))
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: llm-deepseek',
|
||||
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
|
||||
' config:',
|
||||
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
|
||||
'- id: mock-llm',
|
||||
' name: \'./mock-llm.mjs\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
' name: \'@deepseek-ai/dsh-acp-demo\'',
|
||||
' config:',
|
||||
' provider: deepseek',
|
||||
' model: deepseek-v4-flash',
|
||||
' provider: built-acp-mock',
|
||||
' model: built-acp-mock',
|
||||
' persona: \'test agent\'',
|
||||
' workspaceContext: false',
|
||||
'',
|
||||
@@ -113,14 +129,12 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
|
||||
it('boots the published bin, completes a turn, and writes default Zstandard persistence', async () => {
|
||||
consumer = await makeConsumer()
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(consumer, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(consumer, '.agents'),
|
||||
},
|
||||
@@ -151,6 +165,18 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
|
||||
// regression would exit before answering); loadSession proves the real app
|
||||
// mounted, not a collapsed export shape.
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
const { sessionId } = await client.newSession({ cwd: consumer, mcpServers: [] })
|
||||
const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
const sessionsRoot = join(consumer, '.sessions')
|
||||
let log: string | undefined
|
||||
await expect.poll(async () => {
|
||||
log = (await readdir(sessionsRoot, { recursive: true })).find(file => file.endsWith('.jsonl.zstd'))
|
||||
return log
|
||||
}).toBeTypeOf('string')
|
||||
const compressed = await readFile(join(sessionsRoot, log!))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: sessionId })
|
||||
expect(stderr.join('')).not.toContain('without inject')
|
||||
// stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
|
||||
for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
|
||||
@@ -182,7 +208,6 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
|
||||
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
|
||||
@deepseek-ai/dsh-tasks generic background-task registry
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash schema
|
||||
@@ -34,7 +35,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../stdio-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
|
||||
@@ -42,19 +43,21 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
|
||||
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, llmRetry? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
|
||||
|
||||
The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content.
|
||||
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-spine-demo",
|
||||
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
|
||||
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-home": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-home": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as llmRetry from '@deepseek-ai/dsh-llm-retry'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home'
|
||||
|
||||
export const name = 'agent-spine-demo'
|
||||
@@ -77,6 +78,8 @@ export interface Config {
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
|
||||
toolTasks?: toolTasks.Config | false
|
||||
/** Bounded transient model-request retry policy. */
|
||||
llmRetry?: llmRetry.Config
|
||||
}
|
||||
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
@@ -93,6 +96,9 @@ export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
|
||||
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
|
||||
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
|
||||
|
||||
/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */
|
||||
export const LlmRetryConfigSchema: z<llmRetry.Config> = llmRetry.Config
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([
|
||||
AgentLoop.Config,
|
||||
@@ -104,7 +110,8 @@ export const Config = z.intersect([
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
toolBash: ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
|
||||
llmRetry: LlmRetryConfigSchema,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'llmRetry'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -123,6 +130,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
...config.llmRetry !== undefined ? { llmRetry: config.llmRetry } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +148,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const nestedDshHome = config.skills?.local?.dshHome
|
||||
if (config.dshHome !== undefined && nestedDshHome !== undefined
|
||||
&& resolveDshHome(config.dshHome) !== resolveDshHome(nestedDshHome)) {
|
||||
throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory')
|
||||
throw new Error('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory')
|
||||
}
|
||||
const dshHome = resolveDshHome(config.dshHome ?? nestedDshHome)
|
||||
|
||||
@@ -159,6 +167,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
|
||||
}
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(llmRetry, config.llmRetry ?? {})
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
|
||||
|
||||
@@ -10,7 +10,7 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -103,6 +103,16 @@ function messageText(message: Message | undefined): string {
|
||||
return message?.content.map(block => block.type === 'text' ? block.text : '').join('\n') ?? ''
|
||||
}
|
||||
|
||||
class TransientOnceAdapter extends LlmAdapter {
|
||||
requests = 0
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests += 1
|
||||
if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER')
|
||||
yield* textResponse('recovered by bundled policy')
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-agent-spine-demo bundle', () => {
|
||||
it('brings up the full default spine', async () => {
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
@@ -119,6 +129,37 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('loads and configures bounded request recovery for every bundled front door', async () => {
|
||||
const adapter = new TransientOnceAdapter()
|
||||
const ctx = await mount({
|
||||
workspaceContext: false,
|
||||
llmRetry: {
|
||||
maxTransientRetries: 1,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
jitterRatio: 0,
|
||||
},
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('bundled-retry-session'),
|
||||
meta: { cwd: process.cwd() },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
handle.agent.send([{ type: 'text', text: 'recover' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
expect(adapter.requests).toBe(2)
|
||||
const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry')
|
||||
expect(retryEvents).toHaveLength(1)
|
||||
expect(retryEvents[0]?.data.retry).toBe(1)
|
||||
expect(retryEvents[0]?.data.maxRetries).toBe(1)
|
||||
expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy')
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
|
||||
@@ -283,7 +324,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
workspaceContext: false,
|
||||
skills: { local: { dshHome: '/nested-dsh-home' } },
|
||||
})
|
||||
}).toThrow(/must resolve to the same directory/)
|
||||
}).toThrow('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory')
|
||||
})
|
||||
|
||||
it('places workspace instructions before the skill catalog in the session prefix', async () => {
|
||||
@@ -374,6 +415,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: { enabled: false },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: false as const,
|
||||
llmRetry: { maxTransientRetries: 1, jitterRatio: 0 },
|
||||
}
|
||||
|
||||
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
|
||||
@@ -385,6 +427,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
skills: appConfig.skills,
|
||||
toolBash: appConfig.toolBash,
|
||||
toolTasks: appConfig.toolTasks,
|
||||
llmRetry: appConfig.llmRetry,
|
||||
})
|
||||
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
|
||||
})
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-cli-demo
|
||||
|
||||
Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
|
||||
Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
|
||||
|
||||
The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
|
||||
The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -18,7 +18,9 @@ The package mounts no console logger, readline UI, user-interaction service, or
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
|
||||
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
|
||||
| `llmRetry` | owner defaults | bounded transient model-request retry policy |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL session root |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
|
||||
|
||||
## CLI contract
|
||||
@@ -32,7 +34,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task>
|
||||
The root headless-agent example supplies its leaf:
|
||||
|
||||
```sh
|
||||
pnpm run demo:headless -- "inspect the failing test and fix it"
|
||||
pnpm run demo:headless "inspect the failing test and fix it"
|
||||
```
|
||||
|
||||
Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag.
|
||||
@@ -40,7 +42,7 @@ Loader configs with bare package specifiers require `node --expose-internals` or
|
||||
### Output formats
|
||||
|
||||
- `text` writes the last assistant message containing text, followed by one newline.
|
||||
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn.
|
||||
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message.
|
||||
- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
|
||||
|
||||
Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.
|
||||
|
||||
@@ -219,7 +219,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
|
||||
let targetTurn: number | undefined
|
||||
let reason: TurnEndReason | undefined
|
||||
let result = ''
|
||||
let usage: TokenUsage | undefined
|
||||
const usageByStep = new Map<number, TokenUsage>()
|
||||
let outputError: Error | undefined
|
||||
let resolveTurn!: () => void
|
||||
let rejectTurn!: (error: Error) => void
|
||||
@@ -254,9 +254,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
|
||||
targetTurn = event.data.turn
|
||||
}
|
||||
observe(session.id, event)
|
||||
if (event.type === 'assistant/chunk'
|
||||
&& event.data.turn === targetTurn
|
||||
&& event.data.chunk.type === 'usage') {
|
||||
usageByStep.set(event.data.step, event.data.chunk.usage)
|
||||
}
|
||||
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
|
||||
result = assistantText(event) ?? result
|
||||
if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage)
|
||||
if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage)
|
||||
}
|
||||
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
|
||||
reason = event.data.reason
|
||||
@@ -294,6 +299,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
|
||||
}
|
||||
await ctx.sessions.flush(agent.session)
|
||||
if (outputError !== undefined) throw outputError
|
||||
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
|
||||
return {
|
||||
type: 'result',
|
||||
success: reason.kind === 'completed',
|
||||
@@ -365,7 +371,7 @@ export function formatTurnFailure(reason: TurnEndReason): string {
|
||||
switch (reason.kind) {
|
||||
case 'completed': return 'completed'
|
||||
case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}`
|
||||
case 'error': return `failed at step ${reason.step}: ${reason.message}`
|
||||
case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
|
||||
case 'disposed': return 'was disposed'
|
||||
case 'max-tokens': return 'reached the model output-token limit'
|
||||
case 'rejected': return `was rejected: ${reason.reason}`
|
||||
|
||||
@@ -11,7 +11,10 @@ import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
@@ -36,12 +39,16 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-spine-demo. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-spine-demo. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Bounded transient model-request retry policy forwarded through agent-spine-demo. */
|
||||
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
@@ -54,6 +61,7 @@ export const Config: z<Config> = z.object({
|
||||
model: z.string().required(),
|
||||
maxParallelToolCalls: z.number().step(1).min(1),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
persona: z.string(),
|
||||
dshHome: z.string(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
@@ -62,6 +70,7 @@ export const Config: z<Config> = z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
llmRetry: agentCore.LlmRetryConfigSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
@@ -78,5 +87,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }],
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
|
||||
const decompress = promisify(zstdDecompress)
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
|
||||
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
@@ -140,8 +143,13 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
|
||||
const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } })
|
||||
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' })
|
||||
const files = await readdir(join(consumer, '.sessions'), { recursive: true })
|
||||
expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(3)
|
||||
const sessionsRoot = join(consumer, '.sessions')
|
||||
const files = await readdir(sessionsRoot, { recursive: true })
|
||||
const logs = files.filter(file => file.endsWith('.jsonl.zstd'))
|
||||
expect(logs).toHaveLength(3)
|
||||
const compressed = await readFile(join(sessionsRoot, logs[0]!))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
|
||||
}, 30_000)
|
||||
|
||||
it('keeps stdout empty for invalid argv and missing config', async () => {
|
||||
|
||||
@@ -53,12 +53,14 @@ describe('dsh-cli-demo app composition', () => {
|
||||
persona: 'Headless.',
|
||||
tools: { mode: 'native' },
|
||||
persistenceRoot: root,
|
||||
persistenceCompression: 'none',
|
||||
skills: await skillConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
const [agent] = ctx.get('agents')?.roots() ?? []
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
expect(ctx.get('userInteraction')).toBeUndefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
|
||||
|
||||
@@ -70,6 +70,15 @@ function toolResponse(usage: TokenUsage): StreamChunk[] {
|
||||
]
|
||||
}
|
||||
|
||||
function failedResponse(usage: TokenUsage): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'discarded' },
|
||||
{ type: 'usage', usage },
|
||||
{ type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } },
|
||||
]
|
||||
}
|
||||
|
||||
function reasoningResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
@@ -98,6 +107,7 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
|
||||
persistenceRoot: root,
|
||||
skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } },
|
||||
workspaceContext: false,
|
||||
llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
|
||||
@@ -303,7 +313,7 @@ describe('runOneShot and executeCli', () => {
|
||||
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
|
||||
expect(agent.status).toBe('disposed')
|
||||
const files = await readdir(persistenceRoot, { recursive: true })
|
||||
expect(files.some(file => file.endsWith('.jsonl'))).toBe(true)
|
||||
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
|
||||
})
|
||||
|
||||
it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {
|
||||
@@ -323,6 +333,21 @@ describe('runOneShot and executeCli', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a failed retry attempt once even though it has no assistant message', async () => {
|
||||
const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 }
|
||||
const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 }
|
||||
const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)])
|
||||
|
||||
const result = await runOneShot(ctx, { task: 'task' })
|
||||
|
||||
expect(result.usage).toEqual({
|
||||
inputTokens: 18,
|
||||
outputTokens: 7,
|
||||
cacheReadTokens: 3,
|
||||
reasoningTokens: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the prior text when a later assistant message has no text blocks', async () => {
|
||||
const { ctx } = await harness([
|
||||
toolResponse({ inputTokens: 1, outputTokens: 1 }),
|
||||
@@ -463,6 +488,7 @@ describe('formatTurnFailure', () => {
|
||||
[{ kind: 'aborted' }, 'was aborted'],
|
||||
[{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'],
|
||||
[{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'],
|
||||
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
|
||||
[{ kind: 'disposed' }, 'was disposed'],
|
||||
[{ kind: 'max-tokens' }, 'output-token limit'],
|
||||
[{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'],
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# @deepseek-ai/dsh-stdio-demo
|
||||
|
||||
The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`.
|
||||
|
||||
It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it:
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path |
|
||||
| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity |
|
||||
| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity |
|
||||
|
||||
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
|
||||
|
||||
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Routed to |
|
||||
|---|---|---|
|
||||
| `provider` | (required) | the pre-created `main` agent's registered provider route |
|
||||
| `model` | (required) | the pre-created `main` agent's model |
|
||||
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
|
||||
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | terminal banner / TUI subtitle |
|
||||
| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`.
|
||||
|
||||
## Example leaf `cordis.yml`
|
||||
|
||||
```yaml
|
||||
# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app.
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a coding assistant powered by the {{model}} model.'
|
||||
ui:
|
||||
mode: auto
|
||||
```
|
||||
|
||||
Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app".
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Composed terminal agent request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect.
|
||||
|
||||
### Human-answer result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
|
||||
- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package.
|
||||
- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer.
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
|
||||
* coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline
|
||||
* presentation, JSONL session persistence, the user-interaction seam with its
|
||||
* `ask_user_question` tool, and one pre-created agent whose exact shared
|
||||
* agent/session identity the selected UI drives under its `main` display label.
|
||||
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
|
||||
* Loader plugin intentionally exposes named exports only; a default export
|
||||
* would hide its `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-stdio-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
||||
import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiStdio from '@deepseek-ai/dsh-stdio'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'stdio-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
const DEFAULT_WELCOME = 'ready.'
|
||||
|
||||
/** Terminal front door selected by the app bundle. */
|
||||
export type TerminalMode = 'auto' | 'readline' | 'tui'
|
||||
|
||||
/** App-level terminal selection with nested TUI presentation settings. */
|
||||
export interface UiConfig {
|
||||
/** Select a concrete front door or infer it from the process streams. */
|
||||
mode?: TerminalMode
|
||||
/** Settings forwarded only when the pi-tui front door is selected. */
|
||||
tui?: uiTui.TuiConfig
|
||||
}
|
||||
|
||||
const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto')
|
||||
|
||||
/** Schemastery schema for app-level terminal selection. */
|
||||
export const UiConfigSchema: z<UiConfig> = z.object({
|
||||
mode: terminalModeSchema,
|
||||
tui: uiTui.TuiConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the app's terminal front door.
|
||||
* @param config - app-level terminal selection.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
* @returns the concrete UI package to mount.
|
||||
*/
|
||||
export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude<TerminalMode, 'auto'> {
|
||||
const mode = config?.mode ?? 'auto'
|
||||
if (mode === 'auto') return isTTY ? 'tui' : 'readline'
|
||||
if (mode === 'tui' && !isTTY) {
|
||||
throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes')
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||
* {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner and `ui` configures terminal mode/presentation.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Provider route for the `main` agent. */
|
||||
provider: string
|
||||
/** Model name for the `main` agent (must have a registered adapter). */
|
||||
model: string
|
||||
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||
maxParallelToolCalls?: number
|
||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Terminal front-door selection and pi-tui presentation settings. */
|
||||
ui?: UiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/**
|
||||
* If set, the pre-created agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
||||
*/
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
maxParallelToolCalls: z.number().step(1).min(1),
|
||||
persona: z.string(),
|
||||
// The array default is forced to undefined: ABSENT means "lexicographic
|
||||
// order" (the owning dsh-system-prompt schema does the same), while
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
ui: UiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Compose the spine with one terminal front door. Persistence and user
|
||||
* interaction mount first; the selected UI then waits on the exact session id
|
||||
* and subscribes to config-start failures before agent-core starts it. Console
|
||||
* logging is readline-only because fullscreen output belongs to pi-tui. The
|
||||
* ask-user tool waits on the completed spine, and HMR remains a leaf concern.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - app configuration routed to the spine and front door.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
*/
|
||||
export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
const mode = resolveTerminalMode(config.ui, isTTY)
|
||||
if (mode === 'readline') ctx.plugin(ConsoleExporter)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
|
||||
ctx.plugin(UserInteractionService)
|
||||
if (mode === 'tui') {
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui?.tui,
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
} else {
|
||||
ctx.plugin(uiStdio, {
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
ctx.plugin(agentCore, {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
agents: [{
|
||||
id: SessionId('main'),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
}],
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
}
|
||||
|
||||
/** Compose the configured terminal front door with the agent app. */
|
||||
/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered,
|
||||
and the repl-agent PTY smoke covers the interactive process path */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -1,207 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
* require the banner plus echo round-trip. This catches built-only early-exit and config-resolution
|
||||
* failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis
|
||||
* bare-plugin loading, matching the demo command.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
|
||||
|
||||
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
|
||||
// matching an installed dependency rather than tsconfig paths.
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths',
|
||||
'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
'schemastery', 'cosmokit',
|
||||
]
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
return json.name
|
||||
}
|
||||
|
||||
async function installWorkspacePackageCopy(absDir: string, target: string): Promise<void> {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await cp(absDir, target, {
|
||||
recursive: true,
|
||||
filter: source => !source.split('/').includes('node_modules'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
|
||||
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
|
||||
* entries rather than treating them as import failures.
|
||||
*/
|
||||
async function makeConsumer(
|
||||
welcome: string,
|
||||
disabledBrokenEntry = false,
|
||||
extraDshPackages: string[] = [],
|
||||
extraEntries: string[] = [],
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of [...dshPackages, ...extraDshPackages]) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
if (extraDshPackages.includes(rel)) {
|
||||
await installWorkspacePackageCopy(abs, target)
|
||||
} else {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
// The example's mock model + echo tool are example-local TS plugins (Node
|
||||
// 22.19+ — the engines floor — strips types natively, so plain `node` loads
|
||||
// them); they import the workspace packages the symlinked node_modules now
|
||||
// provides.
|
||||
await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
' name: \'./src/mock-llm.ts\'',
|
||||
'- id: echo-tool',
|
||||
' name: \'./src/echo-tool.ts\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: stdio-agent',
|
||||
' name: \'@deepseek-ai/dsh-stdio-demo\'',
|
||||
' config:',
|
||||
' provider: mock',
|
||||
' model: mock-echo',
|
||||
' persona: \'demo\'',
|
||||
' workspaceContext: false',
|
||||
` welcome: '${welcome}'`,
|
||||
...extraEntries,
|
||||
...disabledBrokenEntry
|
||||
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
|
||||
: [],
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */
|
||||
function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// --expose-internals: the cordis Loader resolves bare plugin specifiers via
|
||||
// its internal module loader (active only under this flag); demo:echo passes
|
||||
// it too. NO tsx — this is the published `node lib/bin.js` path.
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => { stdout += c })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
||||
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
child.stdin.write(`${line}\n`)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Windows can briefly retain released handles after exit; retry removal.
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
|
||||
consumer = await makeConsumer('BUILT-BIN-OK ready.')
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stderr).not.toContain('without inject')
|
||||
// The banner proves boot() awaited the tree (the settle-race regression would
|
||||
// exit 0 with empty stdout); the round-trip proves the whole app mounted.
|
||||
expect(stdout).toContain('BUILT-BIN-OK ready.')
|
||||
expect(stdout).toContain('[tool call] echo')
|
||||
expect(stdout).toContain('[tool result] ECHO: HI')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
|
||||
// A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load
|
||||
// guard must not mistake it for a failed import. The nonexistent path makes that distinction
|
||||
// observable while the successful round-trip proves boot continued.
|
||||
consumer = await makeConsumer('DISABLED-OK ready.', true)
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
expect(stderr).not.toContain('failed to load')
|
||||
expect(stdout).toContain('DISABLED-OK ready.')
|
||||
expect(stdout).toContain('[tool result] ECHO: HI')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('boots when optional spill plugins are loaded from a built consumer install', async () => {
|
||||
consumer = await makeConsumer(
|
||||
'SPILL-OK ready.',
|
||||
false,
|
||||
['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'],
|
||||
[
|
||||
'- id: spill-local',
|
||||
' name: \'@deepseek-ai/dsh-spill-local\'',
|
||||
'- id: spill-policy',
|
||||
' name: \'@deepseek-ai/dsh-spill-policy\'',
|
||||
' config:',
|
||||
' maxInlineBytes: 50000',
|
||||
],
|
||||
)
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '')
|
||||
expect(stderr).not.toContain('failed to load')
|
||||
expect(stderr).not.toContain('Cannot find package')
|
||||
expect(stdout).toContain('SPILL-OK ready.')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
|
||||
// directory cannot break its import; the include plugin's own read must fail loud instead.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
|
||||
// Existing directory plus missing config exercises the include plugin's fail-loud path.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,293 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for app composition and config forwarding: pre-created main agent,
|
||||
* agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the
|
||||
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
|
||||
* survive namespace collapse while silently losing its schema.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(stdioAgent, config)
|
||||
// The app mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services + the pre-created agent are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
|
||||
const empty: Message[] = []
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, new AbortController().signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-demo app', () => {
|
||||
it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => {
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui')
|
||||
expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout')
|
||||
})
|
||||
|
||||
it('binds only the selected terminal package to the app-owned exact session identity', () => {
|
||||
const calls: Array<{ name: string; config: unknown }> = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
} as unknown as Context
|
||||
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
welcome: 'TUI ready',
|
||||
ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
|
||||
}, true)
|
||||
expect(calls.map(call => call.name)).toContain('ui-tui')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
|
||||
const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-/)
|
||||
const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as {
|
||||
agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }>
|
||||
}
|
||||
expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
ui: { mode: 'tui' },
|
||||
}, true)
|
||||
expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
|
||||
sessionId: 'persisted-session', welcome: 'ready.',
|
||||
})
|
||||
expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0])
|
||||
.toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' },
|
||||
}, false)
|
||||
expect(calls.map(call => call.name)).toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).toContain('ConsoleExporter')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-tui')
|
||||
})
|
||||
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
// The spine services (brought up by the agent-spine-demo bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||
// The sole pre-created agent the UI drives. `main` is its stable config
|
||||
// label; each fresh process mints a durable combined agent/session id.
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
const agent = ctx.get('agents')?.list()[0]
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
expect(agent?.id).toMatch(/^main-session-/)
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id to a fresh exact app identity', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
resumeSessionId: '',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
const agent = ctx.get('agents')?.list()[0]
|
||||
expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defaults persistenceRoot and welcome when omitted', async () => {
|
||||
// Direct apply (NOT via ctx.plugin, which validates+defaults the config
|
||||
// first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on
|
||||
// apply()'s last two lines are the ones that fire — covering a
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards explicit project-instruction controls to the bundled spine', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context',
|
||||
workspaceContext: false,
|
||||
})
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||
// A resume id defers agent creation until persistence loads; with no backing
|
||||
// session the resume is contained + logged, so no agent registers —
|
||||
// the branch that maps resumeSessionId through is what this covers.
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agents')?.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config and dshHome into agent-spine-demo', async () => {
|
||||
const skills = await isolatedSkillsConfig(6)
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
maxParallelToolCalls: 3,
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-demo')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order',
|
||||
workspaceContext: false,
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
for (const name of ['alpha', 'zulu']) {
|
||||
ctx.get('tools')!.register({
|
||||
name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
|
||||
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
|
||||
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
|
||||
expect('default' in stdioAgent).toBe(false)
|
||||
expect(typeof stdioAgent.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(stdioAgent)
|
||||
expect(unwrapped.name).toBe('stdio-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
102
packages/examples/tui-demo/README.md
Normal file
102
packages/examples/tui-demo/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @deepseek-ai/dsh-tui-demo
|
||||
|
||||
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
|
||||
|
||||
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
|
||||
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
|
||||
|
||||
Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Routed to |
|
||||
|---|---|---|
|
||||
| `provider` | required | Configured `main` agent provider |
|
||||
| `model` | required | Configured `main` agent model |
|
||||
| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap |
|
||||
| `persona` | — | System-prompt persona template |
|
||||
| `toolOrder` | lexicographic | Explicit model-facing tool order |
|
||||
| `tools` | owner default | Tool presentation mode |
|
||||
| `dshHome` | owner default | Harness home used by bash and skills |
|
||||
| `skills` | owner defaults | Skill registry, local provider, and tool config |
|
||||
| `toolBash` | owner defaults | Model-facing bash tool config |
|
||||
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
|
||||
| `workspaceContext` | required | Workspace-instruction config, or `false` |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL persistence root |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `welcome` | `ready.` | TUI subtitle |
|
||||
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
|
||||
| `resumeSessionId` | — | Exact persisted session to resume |
|
||||
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. Bare package specifiers require `node --expose-internals` or the Loader's optional native fallback; the repository scripts use `--expose-internals`.
|
||||
|
||||
## Example leaf
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: tui-agent
|
||||
name: '@deepseek-ai/dsh-tui-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'Coding agent ready.'
|
||||
ui:
|
||||
showReasoning: true
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interactive terminal turn
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token.
|
||||
|
||||
### Human-question answer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the completed or failed tool result adds retained tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; the answer follows the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`.
|
||||
- **One configured terminal session** — the transcript and editor bind to one exact session id.
|
||||
- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition.
|
||||
- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer.
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio-demo",
|
||||
"description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent",
|
||||
"name": "@deepseek-ai/dsh-tui-demo",
|
||||
"description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-stdio-demo": "lib/bin.js"
|
||||
"dsh-tui-demo": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -32,7 +32,6 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
@@ -41,7 +40,6 @@
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-stdio": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -52,7 +50,6 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
@@ -62,7 +59,6 @@
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-stdio": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the
|
||||
* Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the
|
||||
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
|
||||
* dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-stdio-demo/bin
|
||||
* dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-tui-demo/bin
|
||||
*/
|
||||
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh-stdio-demo'
|
||||
const NAME = 'dsh-tui-demo'
|
||||
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user