Merge origin/master into feat/plan-mode
This commit is contained in:
@@ -6,12 +6,21 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries.
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
|
||||
- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
|
||||
- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits.
|
||||
- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal.
|
||||
|
||||
Naming notes:
|
||||
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
- Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme).
|
||||
- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)).
|
||||
- Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme).
|
||||
- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)).
|
||||
|
||||
@@ -8,7 +8,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
@@ -26,14 +26,14 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`mode/`](mode/README.md) | Session-mode policy family: plan mode as logged per-agent state with soft/hard enforcement | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`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/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations |
|
||||
| [`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 |
|
||||
|
||||
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.
|
||||
@@ -42,6 +42,6 @@ Groups distinguish product API from support infrastructure. New packages join an
|
||||
|
||||
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# bash/ — bash capability family
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
|
||||
|
||||
@@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
@@ -22,21 +23,25 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
|
||||
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
|
||||
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
|
||||
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
|
||||
- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
|
||||
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
|
||||
|
||||
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
|
||||
|
||||
@@ -10,7 +10,7 @@ import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
|
||||
import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
@@ -23,7 +23,9 @@ export interface Config {
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
@@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
ctx.effect(() => async () => {
|
||||
// Await closure so even a TERM-trapping child cannot outlive the fiber.
|
||||
@@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: spec.stdoutMaxBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: d.signal,
|
||||
stdin: spec.stdin,
|
||||
@@ -139,6 +144,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: this.config.maxOutputBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
|
||||
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
@@ -72,7 +72,9 @@ export interface SpawnSpec {
|
||||
stdoutMaxBytes: number
|
||||
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stderrMaxBytes: number
|
||||
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs: number
|
||||
/**
|
||||
* Abort signal — kills the process group when it fires. The executor owns
|
||||
@@ -119,6 +121,9 @@ export interface RunInternals {
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
export const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
let spillCounter = 0
|
||||
let defaultSpillDir: string | undefined
|
||||
|
||||
@@ -133,9 +138,9 @@ function privateSpillDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects one stream with a bounded in-memory tail. The FULL stream is
|
||||
* always recoverable: on first overflow a spill file is created and every
|
||||
* chunk (including those already collected) is appended there.
|
||||
* Collects one stream with a bounded in-memory tail. On first overflow a
|
||||
* spill file is created and every chunk (including those already collected)
|
||||
* is appended there while the full stream remains within `maxSpillBytes`.
|
||||
*
|
||||
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
||||
* end of command output; the spill file covers the head.
|
||||
@@ -146,11 +151,13 @@ export class OutputCollector {
|
||||
private dropped = false
|
||||
private spillFd: number | undefined
|
||||
private spillFile: string | undefined
|
||||
private spillDisabled = false
|
||||
/** Total bytes ever pushed (not just retained). */
|
||||
private total = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly maxSpillBytes: number,
|
||||
private readonly label: string,
|
||||
private readonly spillDir: string,
|
||||
) {}
|
||||
@@ -166,7 +173,7 @@ export class OutputCollector {
|
||||
push(chunk: Buffer): void {
|
||||
this.total += chunk.length
|
||||
const overflows = this.bytes + chunk.length > this.maxBytes
|
||||
if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
|
||||
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
|
||||
this.chunks.push(chunk)
|
||||
this.bytes += chunk.length
|
||||
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
|
||||
@@ -188,6 +195,10 @@ export class OutputCollector {
|
||||
|
||||
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
||||
private spillAll(chunk: Buffer): void {
|
||||
if (this.total > this.maxSpillBytes) {
|
||||
this.discardSpill()
|
||||
return
|
||||
}
|
||||
if (this.spillFd === undefined) {
|
||||
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
|
||||
// existing path, symlink or not) + owner-only mode: defeats spill-path
|
||||
@@ -202,6 +213,30 @@ export class OutputCollector {
|
||||
writeSync(this.spillFd, chunk)
|
||||
}
|
||||
|
||||
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
|
||||
private discardSpill(): void {
|
||||
const fd = this.spillFd
|
||||
const file = this.spillFile
|
||||
this.spillFd = undefined
|
||||
this.spillFile = undefined
|
||||
this.spillDisabled = true
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
closeSync(fd)
|
||||
} catch {
|
||||
// Retain the descriptor so finalize can retry the failed close.
|
||||
this.spillFd = fd
|
||||
}
|
||||
}
|
||||
if (file !== undefined) {
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {
|
||||
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental read in whole-stream byte coordinates: returns everything
|
||||
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
||||
@@ -301,8 +336,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
|
||||
const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
|
||||
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
|
||||
|
||||
@@ -328,12 +363,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
}
|
||||
|
||||
const done = new Promise<SpawnOutcome>((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
// No meaningful close outcome follows a spawn failure.
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (exitCode, signal) => {
|
||||
let settled = false
|
||||
let pipeDrainTimer: NodeJS.Timeout | undefined
|
||||
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
cleanup()
|
||||
resolve({
|
||||
exitCode,
|
||||
@@ -341,9 +377,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
stdout: stdout.finalize(),
|
||||
stderr: stderr.finalize(),
|
||||
})
|
||||
}
|
||||
child.on('error', (error) => {
|
||||
// No meaningful close outcome follows a spawn failure.
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (exitCode, signal) => {
|
||||
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
|
||||
})
|
||||
child.on('close', settle)
|
||||
function cleanup(): void {
|
||||
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
||||
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
|
||||
spec.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => {
|
||||
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
|
||||
const { bash } = await setup()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, readFileSync, statSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,7 +6,10 @@ import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
|
||||
import type { RunningBash } from '../src/run.ts'
|
||||
|
||||
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
failNextUnlink: { value: false },
|
||||
}))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
@@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
}
|
||||
actual.closeSync(fd)
|
||||
},
|
||||
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
|
||||
if (failNextUnlink.value) {
|
||||
failNextUnlink.value = false
|
||||
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
|
||||
}
|
||||
actual.unlinkSync(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,6 +39,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
|
||||
cwd: process.cwd(),
|
||||
stdoutMaxBytes: 64_000,
|
||||
stderrMaxBytes: 64_000,
|
||||
maxSpillBytes: 64 * 1024 * 1024,
|
||||
graceMs: 3_000,
|
||||
...overrides,
|
||||
}
|
||||
@@ -173,6 +184,22 @@ describe('runBash', () => {
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||
const started = Date.now()
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
const descendant = await waitForPidFile(pidFile)
|
||||
try {
|
||||
const result = await running.done
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('shell-done\n')
|
||||
} finally {
|
||||
process.kill(descendant, 'SIGKILL')
|
||||
await waitGone(descendant)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
@@ -282,7 +309,7 @@ describe('output truncation and spill', () => {
|
||||
|
||||
describe('OutputCollector', () => {
|
||||
it('keeps the tail of a single oversized chunk', () => {
|
||||
const collector = new OutputCollector(10, 'test', spillDir)
|
||||
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
||||
collector.push(Buffer.from('0123456789abcdef'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('6789abcdef')
|
||||
@@ -291,7 +318,7 @@ describe('OutputCollector', () => {
|
||||
})
|
||||
|
||||
it('readFrom returns increments and flags lossy reads', () => {
|
||||
const collector = new OutputCollector(10, 'test', spillDir)
|
||||
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
||||
collector.push(Buffer.from('aaaaa'))
|
||||
const first = collector.readFrom(0)
|
||||
expect(first.text).toBe('aaaaa')
|
||||
@@ -312,7 +339,7 @@ describe('OutputCollector', () => {
|
||||
})
|
||||
|
||||
it('contains close failures and drops the spill path', () => {
|
||||
const collector = new OutputCollector(4, 'closefail', spillDir)
|
||||
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
expect(collector.readFrom(0).spillPath).toBeDefined()
|
||||
@@ -326,6 +353,46 @@ describe('OutputCollector', () => {
|
||||
expect(out!.truncated).toBe(true)
|
||||
expect(out!.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a spill that exceeds its configured cap', () => {
|
||||
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
const spillPath = collector.readFrom(0).spillPath!
|
||||
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
|
||||
|
||||
collector.push(Buffer.from('c'))
|
||||
collector.push(Buffer.from('dddd'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('dddd')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(out.spillPath).toBeUndefined()
|
||||
expect(() => readFileSync(spillPath)).toThrow()
|
||||
})
|
||||
|
||||
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
|
||||
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
|
||||
collector.push(Buffer.from('abcdefgh'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('efgh')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(out.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains cleanup failures while disabling an oversize spill', () => {
|
||||
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
const spillPath = collector.readFrom(0).spillPath!
|
||||
|
||||
failNextClose.value = true
|
||||
failNextUnlink.value = true
|
||||
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(failNextUnlink.value).toBe(false)
|
||||
expect(collector.finalize().spillPath).toBeUndefined()
|
||||
unlinkSync(spillPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killGroup', () => {
|
||||
|
||||
@@ -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 RFC § Escalation](../../../docs/rfc/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.
|
||||
@@ -38,21 +40,45 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
|
||||
|
||||
### Bash tool schema, indirectly
|
||||
|
||||
**What the model sees**: The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
|
||||
The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the executor advertises the same sandbox capabilities. Changing those capabilities alters the `bash` schema and may invalidate reuse from that definition; per-session mode switches do not.
|
||||
|
||||
### Bash tool result, indirectly
|
||||
|
||||
**What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
|
||||
After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Bash tool error, indirectly
|
||||
|
||||
**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Conditional error text is visible for that call and retained in history until compaction.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional error text is visible for that call and retained in history until compaction.
|
||||
|
||||
#### 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
|
||||
|
||||
|
||||
@@ -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,15 +29,19 @@ 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 RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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).
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept.
|
||||
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.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 })
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export interface BashRunResult {
|
||||
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
|
||||
* both the timeout and the caller's cancellation, so a timeout and an abort
|
||||
* racing before process close report the single first-abort cause, not both
|
||||
* (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
* (see the [timeout-library Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
*/
|
||||
timedOut: boolean
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,58 +57,98 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
|
||||
|
||||
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
|
||||
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
|
||||
|
||||
## Per-session mode switching
|
||||
|
||||
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
**What the model sees**: Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
|
||||
Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
|
||||
|
||||
#### Bash guidance
|
||||
##### Bash guidance
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section; sandbox mode switches do not.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
|
||||
The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while visibility, background support, and executor sandbox capabilities are unchanged. A restriction, config change, or executor change may invalidate reuse from the first changed tool definition.
|
||||
|
||||
### Foreground result
|
||||
|
||||
**What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Background task context and results
|
||||
|
||||
**What the model sees**: Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
|
||||
Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Tool errors
|
||||
|
||||
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
|
||||
|
||||
#### 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
|
||||
|
||||
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
|
||||
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.
|
||||
|
||||
@@ -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}`,
|
||||
...exec.signal ? { 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',
|
||||
...exec.signal ? { 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
|
||||
|
||||
@@ -5,8 +5,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
@@ -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)
|
||||
@@ -39,7 +41,7 @@ afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -50,7 +52,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
@@ -100,11 +102,10 @@ describe('bash tool through the agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter, root, dshHome)
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('session-env'),
|
||||
sessionId: SessionId('session-env-id'),
|
||||
agentOptions: { model: 'mock' },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
const location = ctx.sessionPersistence.locate(agent.session.header)
|
||||
expect(location?.kind).toBe('jsonl')
|
||||
|
||||
@@ -125,7 +126,7 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('The command printed integration-ok.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -157,7 +158,7 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('It failed with code 9.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run exit 9' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -177,7 +178,7 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('Background task finished.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -10,7 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
@@ -50,18 +50,17 @@ async function setupWithTasks() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} whose session token is `sessionId`, give it a
|
||||
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
||||
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
||||
* The agent id is deliberately different from the session token so a
|
||||
* wrong-field ownership match fails the test.
|
||||
*/
|
||||
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id: `agent-${sessionId}`,
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject,
|
||||
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
@@ -182,12 +181,14 @@ 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: 'sandbox-agent',
|
||||
id,
|
||||
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
|
||||
session: {
|
||||
header: { version: 0, id: 'sandbox-session', createdAt: 0 },
|
||||
id,
|
||||
header: { version: 0, id, createdAt: 0 },
|
||||
events,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
const event = { type, data }
|
||||
@@ -286,7 +287,7 @@ describe('bash tool', () => {
|
||||
})
|
||||
|
||||
// Type and required-key violations are rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
|
||||
it.each([
|
||||
[{}, /missing required property "command"/],
|
||||
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
||||
@@ -552,7 +553,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')
|
||||
@@ -609,7 +610,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -945,7 +946,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
* model input into the post-scrub `env` merge or per-run capture budget — NOT
|
||||
* to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
|
||||
* bash-stdin-env Agent Note). Foreground `run()` returns a canned result; `start()`
|
||||
* hands back an already-settled fake handle so the task registration completes.
|
||||
*/
|
||||
class RecordingBashExecutor extends BashExecutor {
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# code-runtime/ — code-execution capability family
|
||||
|
||||
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-code-runtime-worker
|
||||
|
||||
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
|
||||
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -37,6 +37,10 @@ The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. Th
|
||||
|
||||
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW.
|
||||
|
||||
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
|
||||
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
|
||||
|
||||
## Service API (`ctx.codeRuntime`)
|
||||
|
||||
@@ -22,8 +22,12 @@ Semantics every implementation must honor (contract details in the class JSDoc):
|
||||
|
||||
Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
|
||||
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md)).
|
||||
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
|
||||
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# compact/ — compaction capability family
|
||||
|
||||
A three-package capability seam (see [capability seams](../../docs/rfc/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: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `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 RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
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.
|
||||
|
||||
@@ -1,88 +1,101 @@
|
||||
# @deepseek-ai/dsh-compact-basic
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
## What it owns
|
||||
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The 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()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **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.
|
||||
|
||||
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.
|
||||
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`.
|
||||
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `contextWindow` | yes | Context window size in tokens. |
|
||||
| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. |
|
||||
| `retainTokens` | yes | Tokens of recent context to keep intact. |
|
||||
| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). |
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||
| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
||||
| `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
|
||||
| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
export const name = 'compact-basic'
|
||||
export const inject = ['llm']
|
||||
export const inject = ['llm', 'tokenMeter']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(BasicCompactService, {
|
||||
contextWindow: 128000,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
ctx.plugin(TokenMeterService)
|
||||
ctx.plugin(BasicCompactService)
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### Conversation history
|
||||
|
||||
**What the model sees**: Before a step whose estimated envelope and history exceed the threshold, the conversation model receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. This one checkpoint replaces the selected older range and is followed by the retained recent units.
|
||||
#### What the model sees
|
||||
|
||||
**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.
|
||||
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
|
||||
##### Conversation checkpoint preamble
|
||||
|
||||
```markdown
|
||||
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
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
|
||||
|
||||
Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable.
|
||||
|
||||
### Auxiliary summarizer user message
|
||||
|
||||
**What the model sees**: The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
|
||||
The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
|
||||
#### Token effect
|
||||
|
||||
This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token.
|
||||
|
||||
### Auxiliary summarizer system prompt
|
||||
|
||||
**What the model sees**: The summarization model receives the checkpoint-writing instruction below.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
|
||||
The summarization model receives the checkpoint-writing instruction below.
|
||||
|
||||
#### Auxiliary summarizer system prompt
|
||||
##### Auxiliary summarizer system prompt
|
||||
|
||||
```markdown
|
||||
You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.
|
||||
@@ -120,10 +133,19 @@ Rules:
|
||||
- If the transcript already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Token estimation is the chars/`charsPerToken` heuristic** — a marked TODO schedules replacing it with an exact count (a real tokenizer, or provider `usage` fed back) so thresholds track the model's actual budget.
|
||||
- **`estimatePressure()` does not count the request's `tools` field** — pressure is underestimated by the size of the serialized tool schemas the request also carries.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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 RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
- **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)).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-basic",
|
||||
"description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,16 +26,31 @@
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-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"
|
||||
}
|
||||
|
||||
111
packages/compact/compact-basic/src/config.ts
Normal file
111
packages/compact/compact-basic/src/config.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Runtime defaulting and policy validation for compact-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/config
|
||||
*/
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
|
||||
/** Default request-pressure fraction of the token meter's context window. */
|
||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||
|
||||
/** Default verbatim-tail fraction of the token meter's context window. */
|
||||
const DEFAULT_RETAIN_RATIO = 0.16
|
||||
|
||||
/** Complete public configuration key set. */
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'thresholdRatio',
|
||||
'retainTokens',
|
||||
'summarizationProvider',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
'maxOverflowRetries',
|
||||
'auto',
|
||||
])
|
||||
|
||||
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||
function validateConfigKeys(config: BasicCompactConfig): void {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: unknown key "${key}" `
|
||||
+ '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, '
|
||||
+ 'maxTokens, compactionRetries, maxOverflowRetries, auto)',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults and validate the service-wide compaction policy.
|
||||
* @param config - raw compact-basic configuration.
|
||||
* @param tokenMeter - token meter supplying the context capacity.
|
||||
* @returns a detached deeply immutable configuration.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
config: BasicCompactConfig = {},
|
||||
tokenMeter: TokenMeterService,
|
||||
): ResolvedConfig {
|
||||
validateConfigKeys(config)
|
||||
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retainTokens = config.retainTokens
|
||||
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||
const resolved: ResolvedConfig = {
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
summarizationProvider: config.summarizationProvider ?? '',
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
maxOverflowRetries: config.maxOverflowRetries ?? 1,
|
||||
auto: config.auto ?? true,
|
||||
}
|
||||
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio)
|
||||
if (resolved.retainTokens >= thresholdTokens) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
)
|
||||
}
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
||||
if (typeof resolved.summarizationProvider !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider must be a string')
|
||||
}
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string')
|
||||
}
|
||||
if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) {
|
||||
throw new Error(
|
||||
'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty',
|
||||
)
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
return deepFreeze(resolved)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`)
|
||||
}
|
||||
}
|
||||
@@ -1,549 +1,243 @@
|
||||
/**
|
||||
* Basic compaction backend. It estimates request pressure, retains a recent
|
||||
* tool-balanced surface tail, summarizes the older head through a one-shot model
|
||||
* call, and replaces that head with one checkpoint. Auto-compaction runs before
|
||||
* every step so a growing turn can compact its earlier closed steps.
|
||||
* Basic replay-aware compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import z from 'schemastery'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
// 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'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
export { resolveConfig } from './types.ts'
|
||||
export type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/**
|
||||
* Fixed summary structure for resumable checkpoints. A tagged prior checkpoint
|
||||
* is merged with newer history instead of copied forward verbatim.
|
||||
*/
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Tasks',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes a landed summary established context rather than a new request. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* Map a terminal summary failure to an error. A max-token finish is rejected
|
||||
* because committing an incomplete checkpoint would shadow the full history.
|
||||
*/
|
||||
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 'aborted': {
|
||||
const error = new Error('summarization stream aborted') as Error & { code?: string }
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
/** Resolve the exact model durably routed for the latest provider request. */
|
||||
function routedModel(session: Session): string | undefined {
|
||||
const model = session.requestHeader()?.config.model
|
||||
return model === undefined || model.length === 0 ? undefined : model
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend: estimates the surface's token
|
||||
* footprint, summarizes the stale prefix through the model, and shadows it
|
||||
* behind a durable checkpoint. Every threshold/budget knob is required config
|
||||
* ({@link BasicCompactConfig}); the estimator's text density is the
|
||||
* `charsPerToken` knob.
|
||||
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
|
||||
* retention, provenance, and summary-convergence pricing.
|
||||
*
|
||||
* `summarize()` is the sole subclass customization hook; the replay and durable
|
||||
* mutation strategy stays fixed so every pricing decision uses the singleton
|
||||
* token meter.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
static inject = ['llm', 'tokenMeter']
|
||||
|
||||
/** Resolved configuration (`auto` defaulted). */
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
thresholdRatio: z.number().default(0.8),
|
||||
retainTokens: z.number().step(1),
|
||||
summarizationProvider: z.string().default(''),
|
||||
summarizationModel: z.string().default(''),
|
||||
maxTokens: z.number().step(1).min(1).default(8192),
|
||||
compactionRetries: z.number().step(1).min(0).default(1),
|
||||
maxOverflowRetries: z.number().step(1).min(0).default(1),
|
||||
auto: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Resolved and validated compaction configuration. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig) {
|
||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config)
|
||||
|
||||
if (this.config.auto) {
|
||||
// Check before every step so a single growing turn can compact earlier closed steps.
|
||||
// This serial pre-step seam mutates the surface outside the pending step.
|
||||
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||
if (result) {
|
||||
const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
|
||||
ctx.logger.info(
|
||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
||||
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
||||
`~${result.shadowedTokenCount} tokens) ` +
|
||||
`→ ${after} estimated tokens after compaction`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// A failed compaction must not prevent the model call — the surface is
|
||||
// untouched on failure, so the loop derives the full history and the
|
||||
// call proceeds.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.config = resolveConfig(config, ctx.tokenMeter)
|
||||
if (this.config.auto) this._registerAutomaticCompaction()
|
||||
}
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
|
||||
// count — a real tokenizer, or the provider's post-response `usage` (input
|
||||
// tokens) fed back as a correction — so threshold decisions match the
|
||||
// model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*
|
||||
* @param blocks - the blocks to estimate; `tool-result` blocks recurse into
|
||||
* their nested content, and unknown (merge-extended) types fall back to
|
||||
* their JSON-stringified length.
|
||||
* @returns the estimated token count.
|
||||
* Register the automatic post-step pressure and context-overflow recovery
|
||||
* listeners. `compactIfNeeded` stays dynamically dispatched so subclass
|
||||
* overrides are honored at event time.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / charsPerToken)
|
||||
+ Math.ceil(block.arguments.length / charsPerToken)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
|
||||
private _registerAutomaticCompaction(): void {
|
||||
const { ctx } = this
|
||||
const logResult = (result: CompactionResult, trigger: string): void => {
|
||||
ctx.logger.info(
|
||||
`compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes `
|
||||
+ `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, `
|
||||
+ `~${result.shadowedTokenCount} tokens)`,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.on('agent/post-step', async (
|
||||
agent: Agent,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
if (result !== null) logResult(result, 'post-step pressure')
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
let result: CompactionResult | null
|
||||
try {
|
||||
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(
|
||||
// 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
|
||||
|| agent.session.surface.replaceGeneration <= generation) return next()
|
||||
if (result !== null) logResult(result, 'context overflow recovery')
|
||||
return { action: 'retry' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a single session event. Returns 0 for non-message
|
||||
* event types (boundaries, chunks, usage, errors, compact markers).
|
||||
*
|
||||
* @param event - any session event; only the message-bearing types carry
|
||||
* content to count.
|
||||
* @returns the estimated token count of the event's content, or 0 for a
|
||||
* non-message event.
|
||||
* Summarize a rendered region through a direct one-shot `ctx.llm.stream()`
|
||||
* call. Override this sole hook for a template or remote summarizer.
|
||||
* @param text - plain-text conversation region to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
||||
*/
|
||||
estimateEventTokens(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
case 'tool/result':
|
||||
return this.estimateContentTokens(event.data.content)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
protected async summarize(
|
||||
text: string,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total tokens across a list of messages plus optional system prompt.
|
||||
*
|
||||
* @param messages - the derived conversation messages; each adds a fixed
|
||||
* role-framing overhead on top of its content estimate.
|
||||
* @param systemPrompt - counted at chars / `charsPerToken` when provided.
|
||||
* @returns the estimated token footprint of the whole request.
|
||||
*/
|
||||
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
|
||||
let total = 0
|
||||
for (const msg of messages) {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent
|
||||
* step or `agent/request` dispatch. Failure finishes and truncated summaries
|
||||
* reject; the signal is forwarded and only text reaches the checkpoint.
|
||||
*
|
||||
* @param text - plain-text rendering of the conversation region to condense.
|
||||
* @param agent - supplies the fallback model and the session id stamped on
|
||||
* the call; throws when neither it nor the config names a model.
|
||||
* @param signal - optional abort signal, forwarded into the model call.
|
||||
* @returns the text-only summary blocks plus the call envelope used
|
||||
* (`model`, and `maxTokens` when the summarizer has a cap).
|
||||
*/
|
||||
async summarize(
|
||||
text: string, agent: Agent, signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
model: this.config.summarizationModel || agent.options.model || '',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
maxTokens: this.config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
}
|
||||
// exactOptionalPropertyTypes: only set `signal` when present — assigning
|
||||
// `undefined` to an optional `signal?: AbortSignal` is a type error.
|
||||
if (signal) options.signal = signal
|
||||
if (!options.model) {
|
||||
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
|
||||
}
|
||||
for await (const chunk of this.ctx.llm.stream(options)) {
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
const error = finishError(assembler.finish)
|
||||
if (error) throw error
|
||||
|
||||
const summary = this._textOnly(assembler.message().content)
|
||||
if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
|
||||
// config.maxTokens is required and validated positive, so this backend's
|
||||
// envelope always carries the cap; the return type's optionality exists
|
||||
// for overriding subclasses whose summarizer has none.
|
||||
return { summary, model: options.model, maxTokens: this.config.maxTokens }
|
||||
}
|
||||
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
|
||||
/**
|
||||
* The sole pressure gate: count the next request's prefix, derived history,
|
||||
* and system prompt. Above threshold, retain a recent tool-balanced tail and
|
||||
* compact the head, reconsolidating any prior automatic checkpoint. Returns
|
||||
* `null` when no safe or necessary range exists.
|
||||
* Compact for replayed post-step pressure or one provider-confirmed context
|
||||
* overflow. Both triggers price the latest durable routed request envelope;
|
||||
* overflow bypasses the normal threshold and retained-tail policy so it can
|
||||
* force one useful balanced reduction.
|
||||
* @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 summary compaction result, or `null` when no summary ran.
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
fullSystemPrompt: string,
|
||||
sessionPrefix: readonly Message[],
|
||||
trigger: CompactionTrigger,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const session = agent.session
|
||||
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
|
||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||
if (totalTokens < threshold) return result
|
||||
|
||||
const range = this._compactableRange(session)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
|
||||
if (result === null) return null
|
||||
/* v8 ignore next -- paired with the ignored defensive branch above. */
|
||||
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':
|
||||
break
|
||||
}
|
||||
|
||||
result = await this.compactRegion(session, range.start, range.end, agent, signal)
|
||||
case 'pressure':
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(trigger, 'compaction trigger')
|
||||
}
|
||||
|
||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||
if (totalTokens < threshold) return result
|
||||
// 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
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
||||
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||
if (result === null) return null
|
||||
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
||||
break
|
||||
}
|
||||
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||
measurement = meter.measure(agent.session)
|
||||
if (measurement.totalTokens < threshold) return result
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
|
||||
+ `(${totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
+ `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated token pressure of the NEXT request: the session prefix
|
||||
* (`EpochHeader.messagePrefix` — request-only messages the loop sends in
|
||||
* front of the derived history, composed before the pre-step seam and
|
||||
* handed to the gate), the derived history, and the system prompt.
|
||||
* @param session - the session whose next request is being estimated.
|
||||
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
|
||||
* @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
|
||||
* @returns the estimated token total the next request will carry.
|
||||
* Compact one inclusive positional range from the agent-owned surface using
|
||||
* the effective token meter for all retention and shrink pricing.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - owner of the target session, used by the summarizer.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
|
||||
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
// Resolve by surface position: a newer replacement seq may occupy an older slot.
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.findIndex(n => n.seq === start)
|
||||
const endIdx = nodes.findIndex(n => n.seq === end)
|
||||
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
||||
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
|
||||
}
|
||||
|
||||
// Both range edges must preserve assistant tool-call/result pairing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const startNode = nodes[startIdx]!
|
||||
if (!toolPairingBalancedBefore(session, startNode)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const endNode = nodes[endIdx]!
|
||||
if (!toolPairingBalancedAfter(session, endNode)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
if (this._isCompactionInProgress(session)) {
|
||||
throw new Error('compaction already in progress')
|
||||
}
|
||||
|
||||
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
|
||||
// the session-log contract rejects any plugin event appended outside an open turn.
|
||||
const openTurn = this._openTurn(session)
|
||||
if (openTurn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
|
||||
// shadowed range is positional, so this is the set the replace op covers.
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
|
||||
|
||||
// --- Acquire lock ---
|
||||
const startEvent = session.append('compact/start', { turn: openTurn })
|
||||
|
||||
try {
|
||||
// --- Extract text and summarize ---
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
|
||||
|
||||
// Estimate token count of the shadowed content for provenance.
|
||||
let shadowedTokenCount = 0
|
||||
for (const seq of shadowedSeqs) {
|
||||
// seq comes from a surface node — always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
|
||||
}
|
||||
const framedSummary = this._frameSummary(summary)
|
||||
const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
// --- Provenance record (log-only) ---
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
model,
|
||||
...maxTokens !== undefined ? { maxTokens } : {},
|
||||
})
|
||||
|
||||
// --- Surface replacement --- The user/message directly shadows all compacted surface
|
||||
// nodes with a single replace op.
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
|
||||
// --- Release lock (log-only) ---
|
||||
// Appended LAST so the lock brackets the WHOLE operation: a crash between
|
||||
// compact/start and here leaves a detectable orphaned lock (a compact/start
|
||||
// with no matching compact/end) rather than a compact/end that falsely
|
||||
// claims compaction finished before the surface replacement landed.
|
||||
const endEvent = session.append('compact/end', { turn: openTurn })
|
||||
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Always release the lock — append compact/end with the error so a
|
||||
// wedged lock is impossible.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
session.append('compact/end', { turn: openTurn, error: msg })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* Frame the raw summary blocks into the content that lands on the surface:
|
||||
* a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
|
||||
* fresh user request) followed by the summary wrapped in
|
||||
* {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
|
||||
* checkpoint detectable in the transcript on the next compaction cycle, which
|
||||
* triggers the merge rule in the summarization prompt. The raw, unframed
|
||||
* `summary` is preserved separately on the `compact/summary` provenance event.
|
||||
*/
|
||||
private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
|
||||
* (no later `compact/end`) WITHIN the current turn.
|
||||
*/
|
||||
private _isCompactionInProgress(session: Session): boolean {
|
||||
const events = session.events
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
// Index bounded by i >= 0 and i < events.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = events[i]!
|
||||
if (e.type === 'compact/start') return true
|
||||
if (e.type === 'compact/end') break
|
||||
// A turn/end bounds the scan: anything before it belongs to a prior
|
||||
// (closed) turn and cannot be an in-progress compaction of THIS turn.
|
||||
if (e.type === 'turn/end') break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Resolve the next head-anchored compactable surface range, or `null`. */
|
||||
private _compactableRange(session: Session): { start: number; end: number } | null {
|
||||
const nodes = session.surface.nodes
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const events = session.events
|
||||
const retainBudget = this.config.retainTokens
|
||||
|
||||
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
|
||||
// index of the OLDEST node we retain verbatim; everything strictly older
|
||||
// (`[0, keepFromIdx - 1]`) is the compactable range.
|
||||
let accumulated = 0
|
||||
let keepFromIdx = nodes.length // nothing retained yet
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const node = nodes[i]!
|
||||
const event = events[node.seq]
|
||||
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
|
||||
if (event) accumulated += this.estimateEventTokens(event)
|
||||
keepFromIdx = i
|
||||
if (accumulated >= retainBudget) break
|
||||
}
|
||||
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
|
||||
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
|
||||
// retained side head-ward until the cut is balanced, so the compacted range ends without
|
||||
// splitting an assistant↔result pair.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstSeq = nodes[0]!.seq
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
|
||||
return { start: firstSeq, end: cutoffSeq }
|
||||
}
|
||||
|
||||
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
|
||||
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn number of the currently OPEN turn — a `turn/start` not yet
|
||||
* followed by its `turn/end` — or `null` if the session has no open turn.
|
||||
*
|
||||
* Compaction's events must be enclosed in a turn, so scanning back from the
|
||||
* tail: a `turn/start` means that turn is open (return it); a `turn/end` means
|
||||
* the most recent turn already closed (return null). The whole compaction
|
||||
* sequence (compact/start … compact/end) is stamped with this turn.
|
||||
*/
|
||||
private _openTurn(session: Session): number | null {
|
||||
for (let i = session.events.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = session.events[i]!
|
||||
if (e.type === 'turn/start') return e.data.turn
|
||||
if (e.type === 'turn/end') return null
|
||||
}
|
||||
return null
|
||||
const session = agent.session
|
||||
return compactSurfaceRegion({
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||
}, session, start, end, agent, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
197
packages/compact/compact-basic/src/region.ts
Normal file
197
packages/compact/compact-basic/src/region.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Surface retention selection and the log-recorded compaction transaction.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/region
|
||||
*/
|
||||
|
||||
import {
|
||||
renderTranscript,
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { frameSummary } from './summarizer.ts'
|
||||
import type { SummaryResult } from './summarizer.ts'
|
||||
|
||||
interface RegionDependencies {
|
||||
readonly meter: TokenMeterService
|
||||
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the next head-anchored range while retaining a priced recent tail
|
||||
* and never splitting an assistant tool-call/result pair.
|
||||
* @param session - session supplying authoritative current surface positions.
|
||||
* @param measurement - unified pressure and surface measurement from the conversation meter.
|
||||
* @param retainTokens - minimum recent tail budget retained verbatim.
|
||||
* @returns the inclusive positional seq range to compact, or `null`.
|
||||
*/
|
||||
export function selectCompactableRange(
|
||||
session: Session,
|
||||
measurement: TokenMeasurement,
|
||||
retainTokens: number,
|
||||
): { start: number; end: number } | null {
|
||||
const pricedNodes = measurement.nodes
|
||||
if (pricedNodes.length === 0) return null
|
||||
|
||||
const surfaceNodes = session.surface.nodes
|
||||
if (surfaceNodes.length !== pricedNodes.length
|
||||
|| surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
|
||||
throw new Error('compaction: token-meter surface does not match the current session surface')
|
||||
}
|
||||
|
||||
let accumulated = 0
|
||||
let keepFromIdx = pricedNodes.length
|
||||
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
accumulated += pricedNodes[index]!.tokens
|
||||
keepFromIdx = index
|
||||
if (accumulated >= retainTokens) break
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const first = surfaceNodes[0]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoff = surfaceNodes[keepFromIdx - 1]!
|
||||
return { start: first, end: cutoff }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and compact one positional surface span.
|
||||
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
|
||||
* @param session - session whose surface is mutated.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - agent used by the summarizer.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
export async function compactSurfaceRegion(
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.indexOf(start)
|
||||
const endIdx = nodes.indexOf(end)
|
||||
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
||||
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(
|
||||
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
const tail = inspectTurnTail(session.events)
|
||||
if (tail.compactionInProgress) throw new Error('compaction already in progress')
|
||||
if (tail.turn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
||||
const startEvent = session.append('compact/start', { turn: tail.turn })
|
||||
try {
|
||||
// Capture after the lock event so any later durable append, including a
|
||||
// log-only one, invalidates the async selection before replacement.
|
||||
const lockedMeasurement = dependencies.meter.measure(session)
|
||||
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
|
||||
if (selected.length !== shadowedSeqs.length
|
||||
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
|
||||
throw new Error('compaction: selected surface changed before summarization began')
|
||||
}
|
||||
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
throw new Error('compaction: session log changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage({
|
||||
role: 'user',
|
||||
content: framedSummary,
|
||||
})
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
provider,
|
||||
model,
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: tail.turn })
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
session.append('compact/end', { turn: tail.turn, error: message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the current turn boundary and latest compaction bracket once. */
|
||||
function inspectTurnTail(
|
||||
events: readonly SessionEvent[],
|
||||
): { turn: number | null; compactionInProgress: boolean } {
|
||||
let compactionInProgress = false
|
||||
let compactionStateKnown = false
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (!compactionStateKnown) {
|
||||
if (event.type === 'compact/start') {
|
||||
compactionInProgress = true
|
||||
compactionStateKnown = true
|
||||
} else if (event.type === 'compact/end') {
|
||||
compactionStateKnown = true
|
||||
}
|
||||
}
|
||||
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
|
||||
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
|
||||
}
|
||||
return { turn: null, compactionInProgress }
|
||||
}
|
||||
165
packages/compact/compact-basic/src/summarizer.ts
Normal file
165
packages/compact/compact-basic/src/summarizer.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Default one-shot summarization and durable checkpoint framing.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/summarizer
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ResolvedConfig } from './types.ts'
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/** Fixed structure required from the auxiliary summarization call. */
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Tasks',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes the replacement user message established context. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
|
||||
export interface SummaryResult {
|
||||
summary: ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the default direct `ctx.llm.stream()` summarization call.
|
||||
* @param ctx - context providing the LLM service.
|
||||
* @param config - resolved backend configuration.
|
||||
* @param text - rendered transcript region to summarize.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text-only summary blocks and exact call provenance.
|
||||
*/
|
||||
export async function summarizeWithLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedConfig,
|
||||
text: string,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
const latest = agent.session.requestHeader()?.config
|
||||
const configured = config.summarizationProvider.length === 0
|
||||
? undefined
|
||||
: { provider: config.summarizationProvider, model: config.summarizationModel }
|
||||
const agentTarget = agent.options.provider !== undefined
|
||||
&& agent.options.provider.length > 0
|
||||
&& agent.options.model !== undefined
|
||||
&& agent.options.model.length > 0
|
||||
? { provider: agent.options.provider, model: agent.options.model }
|
||||
: undefined
|
||||
const target = configured ?? latest ?? agentTarget
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
'no provider/model available for summarization: set both BasicCompactConfig summarization fields, route one request, or set both AgentOptions fields',
|
||||
)
|
||||
}
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
maxTokens: config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
...signal === undefined ? {} : { signal },
|
||||
}
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
const error = finishError(assembler.finish)
|
||||
if (error !== undefined) throw error
|
||||
|
||||
const summary = textOnly(assembler.message().content)
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
return {
|
||||
summary,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
maxTokens: config.maxTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw summary blocks in the durable checkpoint framing.
|
||||
* @param summary - safe text-only model output.
|
||||
* @returns content for the synthesized replacement user message.
|
||||
*/
|
||||
export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/** Map a terminal summarization finish to its fail-closed error. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep only text blocks before synthesizing a user message. */
|
||||
function textOnly(
|
||||
blocks: readonly ContentBlock[],
|
||||
): Array<Extract<ContentBlock, { type: 'text' }>> {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
@@ -1,94 +1,37 @@
|
||||
/**
|
||||
* Configuration vocabulary for the basic compaction backend.
|
||||
*
|
||||
* Every tunable lives here, in the implementation — the abstract contract
|
||||
* (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and
|
||||
* retention policy are HOW decisions a different backend would make
|
||||
* differently.
|
||||
* Configuration vocabulary for the replay-aware basic compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
/** Basic compaction configuration; every common field has a deployment default. */
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
contextWindow: number
|
||||
/** Compact when estimated token usage exceeds this fraction of context window. */
|
||||
thresholdRatio: number
|
||||
/** Number of tokens of recent context to retain during compaction. */
|
||||
retainTokens: number
|
||||
/** Model to use for summarization (`''` — uses the agent's model). */
|
||||
summarizationModel: string
|
||||
/** Provider generation cap for the summarization call. */
|
||||
maxTokens: number
|
||||
/** Extra compaction attempts when the first compacted surface is still over threshold. */
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
|
||||
thresholdRatio?: number
|
||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||
retainTokens?: number
|
||||
/** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationProvider?: string
|
||||
/** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
maxTokens?: number
|
||||
/** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */
|
||||
compactionRetries?: number
|
||||
/** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */
|
||||
maxOverflowRetries?: number
|
||||
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` and `charsPerToken` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* @param config - the raw, unresolved backend config.
|
||||
* @returns the validated config with `auto` and `charsPerToken` defaulted.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean.')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
}
|
||||
/** Validated and detached compaction configuration. */
|
||||
export interface ResolvedConfig {
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
readonly maxOverflowRetries: number
|
||||
readonly auto: boolean
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
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'
|
||||
|
||||
/**
|
||||
* CBR-001 regression through the real loop. A replacement checkpoint has a high
|
||||
@@ -18,15 +21,13 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
* surface-position semantics rather than raw-log scanning.
|
||||
*/
|
||||
|
||||
const TOKENS_PER_BLOCK = 10
|
||||
|
||||
class ReproCompactService extends BasicCompactService {
|
||||
override estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
return blocks.length * TOKENS_PER_BLOCK
|
||||
}
|
||||
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
|
||||
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; provider: string; model: string }> {
|
||||
return {
|
||||
summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }],
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +57,59 @@ class StepwiseToolAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** First conversation request overflows, then the rebuilt retry succeeds. */
|
||||
class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
readonly conversationRequests: GenerateOptions[] = []
|
||||
readonly summaryRequests: GenerateOptions[] = []
|
||||
|
||||
constructor(
|
||||
private readonly delivery: 'thrown' | 'in-band',
|
||||
private readonly transientAfterOverflow = false,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.system?.includes('You are a compaction engine')) {
|
||||
this.summaryRequests.push(options)
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
return
|
||||
}
|
||||
|
||||
this.conversationRequests.push(options)
|
||||
if (this.conversationRequests.length === 1) {
|
||||
if (this.delivery === 'thrown') {
|
||||
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
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' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
@@ -70,13 +119,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
return [{ type: 'text', text: 'work result' }]
|
||||
},
|
||||
}))
|
||||
// Tiny window so a couple of tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn.
|
||||
// Small window so several tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn after enough history can shrink.
|
||||
const compact = new ReproCompactService(ctx, {
|
||||
auto: true,
|
||||
contextWindow: 64,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 20,
|
||||
retainTokens: 50,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
@@ -84,7 +132,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
return { ctx, compact }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -95,11 +143,82 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): 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)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
agent.send([{ type: 'text', text: 'do a routed multi-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.requestHeader()?.config.model).toBe('mock')
|
||||
expect(agent.session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do tool work' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const compactStart = events.find(event => event.type === 'compact/start')
|
||||
expect(compactStart).toBeDefined()
|
||||
const precedingResult = events.findLast(event =>
|
||||
event.type === 'tool/result' && event.seq < compactStart!.seq,
|
||||
)
|
||||
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
|
||||
const stepEnd = events.find(event =>
|
||||
event.type === 'step/end'
|
||||
&& event.data.step === precedingResult.data.step
|
||||
&& event.seq > compactStart!.seq,
|
||||
)
|
||||
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -116,15 +235,124 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
// its start and end cuts are balanced in surface order.
|
||||
const nodes = agent.session.surface.nodes
|
||||
for (const cp of checkpoints) {
|
||||
const node = nodes.find(n => n.seq === cp.seq)
|
||||
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(toolPairingBalancedBefore(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
|
||||
expect(toolPairingBalancedAfter(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
|
||||
const index = nodes.indexOf(cp.seq)
|
||||
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(toolPairingBalancedBefore(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
|
||||
expect(toolPairingBalancedAfter(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('context-overflow recovery across the real loop and compact-basic', () => {
|
||||
it.each(['thrown', 'in-band'] as const)(
|
||||
'force-compacts a %s overflow between failed and retry steps',
|
||||
async (delivery) => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter(delivery)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
maxTokens: 64,
|
||||
compactionRetries: 0,
|
||||
maxOverflowRetries: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
seedOverflowHistory(agent)
|
||||
|
||||
agent.send([{ type: 'text', text: 'continue from history' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
|
||||
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
|
||||
expect(retry).toContain('RECOVERY CHECKPOINT')
|
||||
expect(retry).not.toContain('OLD HISTORY SENTINEL')
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const failedEnd = events.find(event =>
|
||||
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const retryStart = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
|
||||
)!
|
||||
const compaction = events.filter(event =>
|
||||
event.type === 'compact/start'
|
||||
|| event.type === 'compact/summary'
|
||||
|| event.type === 'compact/end',
|
||||
)
|
||||
expect(compaction.map(event => event.type)).toEqual([
|
||||
'compact/start',
|
||||
'compact/summary',
|
||||
'compact/end',
|
||||
])
|
||||
expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
102
packages/compact/compact-basic/tests/loader-composition.spec.ts
Normal file
102
packages/compact/compact-basic/tests/loader-composition.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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 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
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [...lines, ''].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
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 = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
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',
|
||||
' retainTokens: 512',
|
||||
' auto: false',
|
||||
])
|
||||
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(loaded.tokenMeter.contextWindow).toBe(4096)
|
||||
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
|
||||
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
expect((loaded.compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 512,
|
||||
auto: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale token-meter config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(TokenMeterService, {
|
||||
models: { legacy: { contextWindow: 4096 } },
|
||||
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/)
|
||||
})
|
||||
|
||||
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
models: { legacy: { thresholdRatio: 0.5 } },
|
||||
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,12 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../llm/token-meter" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../compact" }
|
||||
{ "path": "../compact" },
|
||||
{ "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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio",
|
||||
"description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio",
|
||||
"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",
|
||||
@@ -22,23 +22,21 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-mode": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -7,27 +7,29 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. 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).
|
||||
|
||||
## Service API (`ctx.compact`)
|
||||
|
||||
Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization).
|
||||
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
||||
| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
||||
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
|
||||
The interface exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
|
||||
|
||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||
|
||||
## Surface contract
|
||||
|
||||
@@ -35,8 +37,8 @@ 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,
|
||||
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**,
|
||||
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 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.
|
||||
@@ -53,25 +55,40 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
|
||||
|
||||
## Implementing a backend
|
||||
|
||||
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A tokenizer-, template-, or model-backed implementation can live as a sibling package without changing callers.
|
||||
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Conversation history, when a backend is invoked
|
||||
|
||||
**What the model sees**: A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
|
||||
|
||||
### Transcript supplied to a compaction consumer
|
||||
|
||||
**What the model sees**: `renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
|
||||
`renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
|
||||
- **Single-unit overflow is out of contract** — one retained unit (a closed step or a large pasted `user/message`) alone exceeding the budget cannot be compacted; the call may go out over-budget.
|
||||
- **A session prefix that alone approaches the window is a configuration error no backend fixes** — compaction shrinks derived history, never the prefix.
|
||||
- **Request context injected by downstream `agent/request` listeners sits outside pressure accounting** — `compactIfNeeded` counts prefix, derived history, and system prompt only.
|
||||
- **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.
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
* compact and replace a history range with one summary node by subclassing
|
||||
* {@link CompactService}. This interface necessarily depends on session and LLM
|
||||
* vocabulary; the rationale is in the
|
||||
* [compaction RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
* [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
* @module @deepseek-ai/dsh-compact
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
@@ -16,10 +15,13 @@ export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Why automatic policy is asking a backend to consider compaction. */
|
||||
export type CompactionTrigger = 'pressure' | 'context-overflow'
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
session: Session
|
||||
options: { model?: string }
|
||||
options: { provider?: string; model?: string }
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -29,10 +31,11 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract compaction service. Implementations own token estimation, retention,
|
||||
* and summarization, but a successful run must replace the selected surface span
|
||||
* with one summary node and prevent concurrent compaction of the same session.
|
||||
* Load one implementation per context as `ctx.compact`.
|
||||
* Abstract compaction service. Implementations own trigger policy, retention,
|
||||
* and summarization, and may consume a separate measurement service. A
|
||||
* successful run replaces the selected surface span with one summary node and
|
||||
* prevents concurrent compaction of the same session. Load one implementation
|
||||
* per context as `ctx.compact`.
|
||||
*/
|
||||
export abstract class CompactService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
@@ -40,24 +43,20 @@ export abstract class CompactService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token pressure and compact if the conversation is too large.
|
||||
* Estimate the next request, including its session prefix, derived history,
|
||||
* and system prompt. Above threshold, compact a head-anchored range ending at
|
||||
* a balanced tool boundary and reconsolidate any prior automatic checkpoint.
|
||||
* Return `null` when no compaction is needed or an open tail leaves no safe
|
||||
* cutoff. A single oversized retained unit or prefix cannot be repaired here.
|
||||
* Consider automatic compaction for one explicit trigger. Pressure policy
|
||||
* uses the latest durable routed request, while context-overflow policy may
|
||||
* force a useful balanced reduction even below the normal threshold. Return
|
||||
* `null` when no safe range can be compacted. A single oversized retained
|
||||
* unit or request envelope cannot be repaired through surface compaction.
|
||||
*
|
||||
* @param agent - agent context owning the session surface and model options.
|
||||
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
|
||||
* @param sessionPrefix - the instance's composed session prefix, counted toward the
|
||||
* estimate.
|
||||
* @param agent - agent context owning the session surface and routing options.
|
||||
* @param trigger - normal pressure or provider-confirmed context overflow.
|
||||
* @param signal - cancellation signal; model-backed implementations must forward it.
|
||||
* @returns the compaction result, or `null` if no compaction was needed.
|
||||
*/
|
||||
abstract compactIfNeeded(
|
||||
agent: CompactAgentContext,
|
||||
fullSystemPrompt: string,
|
||||
sessionPrefix: readonly Message[],
|
||||
trigger: CompactionTrigger,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
@@ -67,20 +66,18 @@ export abstract class CompactService extends Service {
|
||||
* order; replacements can make visible seqs non-monotonic. Both edges must be
|
||||
* balanced so assistant tool calls remain paired with their results. A model-
|
||||
* backed implementation forwards cancellation and rejects active, missing,
|
||||
* reversed, or unbalanced ranges.
|
||||
* reversed, or unbalanced ranges. The target session is `agent.session`.
|
||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||
* for the edge checks.
|
||||
*
|
||||
* @param session - session to mutate.
|
||||
* @param start - first surface seq, inclusive.
|
||||
* @param end - last surface seq, inclusive.
|
||||
* @param agent - summarizer context.
|
||||
* @param agent - context whose session is mutated and whose routing options guide summarization.
|
||||
* @param signal - optional cancellation; model-backed implementations must forward it.
|
||||
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
|
||||
* @returns the replaced range and summary.
|
||||
* @returns the appended event seqs, summary, replaced range, and token accounting.
|
||||
*/
|
||||
abstract compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: CompactAgentContext,
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content in current
|
||||
* surface order rather than step markers or linked-list fields supplied by a
|
||||
* caller.
|
||||
* surface order rather than step markers.
|
||||
* @module @deepseek-ai/dsh-compact/tool-pairing
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Incremental balance state for one session surface generation. */
|
||||
interface BalanceCache {
|
||||
/** Surface rewrite generation this state describes. */
|
||||
generation: number
|
||||
/**
|
||||
* Balance of every surface cut in current order: a surface of N nodes has
|
||||
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
|
||||
* Balance of every surface cut in current order: a surface of N sequences has
|
||||
* N + 1 cuts, entry `i` being the cut before sequence `i` and the final entry
|
||||
* the cut after the surface tail.
|
||||
*/
|
||||
cutBalanced: readonly boolean[]
|
||||
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
|
||||
/** Current surface position of each event seq, indexing {@link cutBalanced}. */
|
||||
indexBySeq: Map<number, number>
|
||||
/** In-progress tool-call count after the processed surface tail. */
|
||||
inProgressToolCalls: number
|
||||
@@ -27,7 +26,7 @@ interface BalanceCache {
|
||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
|
||||
/** Return how one surface event changes the in-progress tool-call count. */
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
function eventDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
@@ -38,37 +37,37 @@ function nodeDelta(event: SessionEvent): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the event named by a surface node. */
|
||||
function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent {
|
||||
const event = events[node.seq]
|
||||
if (event === undefined || event.seq !== node.seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`)
|
||||
/** Read and validate the event named by a surface sequence. */
|
||||
function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent {
|
||||
const event = events[seq]
|
||||
if (event === undefined || event.seq !== seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** Fold surface nodes not yet in the cache into its balance state. */
|
||||
/** Fold surface sequences not yet in the cache into its balance state. */
|
||||
function extendCache(
|
||||
session: Session,
|
||||
cache: BalanceCache,
|
||||
nodes: readonly SurfaceNode[],
|
||||
seqs: readonly number[],
|
||||
): BalanceCache {
|
||||
const processed = cache.cutBalanced.length - 1
|
||||
const tail = nodes.slice(processed)
|
||||
const tail = seqs.slice(processed)
|
||||
// Validate the unseen tail before mutating the live cache, so a corrupt
|
||||
// append cannot leave a partially advanced state behind.
|
||||
const events = session.events
|
||||
const pendingCuts: boolean[] = []
|
||||
let inProgressToolCalls = cache.inProgressToolCalls
|
||||
for (const node of tail) {
|
||||
inProgressToolCalls += nodeDelta(eventForNode(events, node))
|
||||
for (const seq of tail) {
|
||||
inProgressToolCalls += eventDelta(eventForSeq(events, seq))
|
||||
if (inProgressToolCalls < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
pendingCuts.push(inProgressToolCalls === 0)
|
||||
}
|
||||
|
||||
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
|
||||
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
|
||||
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||
cache.inProgressToolCalls = inProgressToolCalls
|
||||
return cache
|
||||
@@ -77,11 +76,11 @@ function extendCache(
|
||||
/** Return balance state synchronized with the current session surface. */
|
||||
function balanceCache(session: Session): BalanceCache {
|
||||
const surface = session.surface
|
||||
const nodes = surface.nodes
|
||||
const seqs = surface.nodes
|
||||
const generation = surface.replaceGeneration
|
||||
const cached = balanceCacheBySession.get(session)
|
||||
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
|
||||
// A rebuild is the same fold started from the empty-surface state, whose
|
||||
// single leading cut is trivially balanced.
|
||||
const rebuilt = extendCache(session, {
|
||||
@@ -89,15 +88,15 @@ function balanceCache(session: Session): BalanceCache {
|
||||
cutBalanced: [true],
|
||||
indexBySeq: new Map(),
|
||||
inProgressToolCalls: 0,
|
||||
}, nodes)
|
||||
}, seqs)
|
||||
balanceCacheBySession.set(session, rebuilt)
|
||||
return rebuilt
|
||||
}
|
||||
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
|
||||
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
|
||||
/** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */
|
||||
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
const index = cache.indexBySeq.get(seq)
|
||||
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
|
||||
@@ -108,25 +107,25 @@ function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately before a current surface node is tool-pairing balanced.
|
||||
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose leading cut is checked; only its seq identifies it.
|
||||
* @param seq - event sequence whose leading cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface node has no
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 0)
|
||||
export function toolPairingBalancedBefore(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately after a current surface node is tool-pairing balanced.
|
||||
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose trailing cut is checked; only its seq identifies it.
|
||||
* @param seq - event sequence whose trailing cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface node has no
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 1)
|
||||
export function toolPairingBalancedAfter(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 1)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Those declaration-merged events are log-only lock/provenance markers, not
|
||||
* surface events; a separate replacement `user/message` carries the summary.
|
||||
* Backend packages own configuration and retention policy; see
|
||||
* `docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md`.
|
||||
* `.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md`.
|
||||
* @module @deepseek-ai/dsh-compact/types
|
||||
*/
|
||||
|
||||
@@ -24,11 +24,13 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
shadowedRange: { start: number; end: number }
|
||||
shadowedSeqs: number[]
|
||||
shadowedTokenCount: number
|
||||
/** The provider route that wrote the summary. */
|
||||
provider: string
|
||||
/**
|
||||
* The model that wrote the summary — the summarize call's envelope,
|
||||
* reported by the backend that made the call, logged so the one-shot
|
||||
* request is reconstructable from log + code and "which model wrote
|
||||
* this summary" has a durable answer (the reconstructability RFC).
|
||||
* this summary" has a durable answer (the reconstructability Agent Note).
|
||||
*/
|
||||
model: string
|
||||
/** The generation cap the summarize call sent, when one applied. */
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
|
||||
@@ -18,8 +17,7 @@ class StubCompactService extends CompactService {
|
||||
|
||||
override async compactIfNeeded(
|
||||
_agent: CompactAgentContext,
|
||||
_fullSystemPrompt: string,
|
||||
_sessionPrefix: readonly Message[],
|
||||
_trigger: CompactionTrigger,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.lastSignal = signal
|
||||
@@ -27,20 +25,22 @@ class StubCompactService extends CompactService {
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
_agent: CompactAgentContext,
|
||||
agent: CompactAgentContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
this.lastSignal = signal
|
||||
const session = agent.session
|
||||
const summary = [{ type: 'text' as const, text: 'stub' }]
|
||||
// Minimal stub honoring the lock + log-only event contract.
|
||||
const startEvent = session.append('compact/start', { turn: 0 })
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: 0 })
|
||||
@@ -48,7 +48,7 @@ class StubCompactService extends CompactService {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
@@ -80,7 +80,7 @@ describe('CompactService seam', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull()
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
|
||||
})
|
||||
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
@@ -88,7 +88,7 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
|
||||
const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'))
|
||||
const result = await svc.compactRegion(0, 0, stubAgent(session, 'm'))
|
||||
|
||||
const startEvent = session.events.find(e => e.type === 'compact/start')
|
||||
expect(startEvent).toBeDefined()
|
||||
@@ -96,8 +96,12 @@ describe('CompactService seam', () => {
|
||||
// verify the runtime value is absent.
|
||||
const raw = startEvent as unknown as { surfaceOp?: unknown }
|
||||
expect(raw.surfaceOp).toBeUndefined()
|
||||
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
|
||||
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
|
||||
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
|
||||
expect(result.shadowedRange).toEqual({ start: 0, end: 0 })
|
||||
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
|
||||
.toEqual(['compact/start', 'compact/summary', 'compact/end'])
|
||||
})
|
||||
|
||||
it('threads the cancellation signal through to the backend', async () => {
|
||||
@@ -106,10 +110,10 @@ describe('CompactService seam', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
|
||||
await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
|
||||
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
|
||||
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('renderTranscript', () => {
|
||||
content: [{ type: 'text', text: 'fix the bug' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = s.append('assistant/message', {
|
||||
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'looking' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -111,7 +111,7 @@ describe('renderTranscript', () => {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyAssistant = s.append('assistant/message', {
|
||||
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
@@ -10,18 +10,18 @@ function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return session.events.filter(event => event.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
function nodeAt(session: Session, seq: number): SurfaceNode {
|
||||
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
|
||||
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return node
|
||||
function surfaceSeq(session: Session, seq: number): number {
|
||||
const current = session.surface.nodes.find(candidate => candidate === seq)
|
||||
if (current === undefined) throw new Error(`seq ${seq} is not on the surface`)
|
||||
return current
|
||||
}
|
||||
|
||||
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth)))
|
||||
return toolPairingBalancedBefore(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
|
||||
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
@@ -34,6 +34,7 @@ function closedToolStep(): Session {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
@@ -60,6 +61,7 @@ describe('tool-pairing boundaries', () => {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
@@ -73,6 +75,7 @@ describe('tool-pairing boundaries', () => {
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
@@ -91,6 +94,7 @@ describe('tool-pairing boundaries', () => {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
@@ -113,9 +117,9 @@ describe('tool-pairing boundaries', () => {
|
||||
})
|
||||
|
||||
describe('tool-pairing surface identity', () => {
|
||||
it('rebuilds after replace and rejects nodes removed from current membership', () => {
|
||||
it('rebuilds after replace and rejects sequences removed from current membership', () => {
|
||||
const session = closedToolStep()
|
||||
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
|
||||
const staleTail = surfaceSeq(session, seqOf(session, 'tool/result'))
|
||||
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
@@ -123,8 +127,8 @@ describe('tool-pairing surface identity', () => {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq },
|
||||
sourceEventSeqs: nodes.map(node => node.seq),
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
@@ -134,16 +138,16 @@ describe('tool-pairing surface identity', () => {
|
||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
})
|
||||
|
||||
it('ignores a caller-held node next field and answers from cached balances', () => {
|
||||
it('answers repeated queries from cached balances', () => {
|
||||
const session = closedToolStep()
|
||||
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
|
||||
const assistant = surfaceSeq(session, seqOf(session, 'assistant/message'))
|
||||
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
|
||||
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing seqs before and after, including an empty surface', () => {
|
||||
const session = new Session(SessionId('missing-membership'))
|
||||
const missing: SurfaceNode = { seq: 999, prev: null, next: null }
|
||||
const missing = 999
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
@@ -165,7 +169,12 @@ describe('tool-pairing cache refresh', () => {
|
||||
},
|
||||
{
|
||||
type: 'assistant/message', seq: 1, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -174,11 +183,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: 2 },
|
||||
{ seq: 2, prev: 1, next: null },
|
||||
]
|
||||
const nodes: number[] = [0, 1, 2]
|
||||
let generation = 0
|
||||
let eventCollectionReads = 0
|
||||
let eventIndexReads = 0
|
||||
@@ -222,7 +227,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push({ seq: 4, prev: 2, next: null })
|
||||
nodes.push(4)
|
||||
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(2)
|
||||
expect(eventIndexReads).toBe(4)
|
||||
@@ -230,7 +235,12 @@ describe('tool-pairing cache refresh', () => {
|
||||
events.push(
|
||||
{
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
|
||||
data: {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -239,10 +249,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
nodes.push(
|
||||
{ seq: 5, prev: 4, next: 6 },
|
||||
{ seq: 6, prev: 5, next: null },
|
||||
)
|
||||
nodes.push(5, 6)
|
||||
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(3)
|
||||
expect(eventIndexReads).toBe(6)
|
||||
@@ -252,14 +259,14 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
|
||||
nodes.splice(0, nodes.length, 7)
|
||||
generation += 1
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(4)
|
||||
expect(eventIndexReads).toBe(7)
|
||||
})
|
||||
|
||||
it('rebuilds defensively when a same-generation surface node count regresses', () => {
|
||||
it('rebuilds defensively when a same-generation surface entry count regresses', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
@@ -270,10 +277,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: null },
|
||||
]
|
||||
const nodes: number[] = [0, 1]
|
||||
const session = {
|
||||
events,
|
||||
surface: { nodes, replaceGeneration: 0 },
|
||||
@@ -307,24 +311,24 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
})
|
||||
|
||||
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
|
||||
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
|
||||
const missingSeq = 1
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingNode], replaceGeneration: 0 },
|
||||
surface: { nodes: [missingSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
|
||||
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
|
||||
|
||||
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
|
||||
const mismatchedSeq = 0
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
|
||||
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,4 +7,4 @@ Product plugins that add model-visible request context without defining a tool o
|
||||
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
|
||||
|
||||
The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-time-context
|
||||
|
||||
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -26,30 +26,38 @@ Step 1 measures from the latest preceding model-visible message, including the p
|
||||
|
||||
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Preparation-time temporal context
|
||||
|
||||
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
|
||||
|
||||
#### First step
|
||||
##### First step
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Later steps
|
||||
##### Later steps
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
|
||||
#### 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
|
||||
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
@@ -162,8 +161,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
_fullSystemPrompt: string,
|
||||
_sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Test-only composition: keep time-context opt-in while exercising its real Loader/app path.
|
||||
- id: mock-llm
|
||||
name: '../../../../../examples/echo-agent/src/mock-llm.ts'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: mock-echo
|
||||
persona: 'Test the time-context plugin.'
|
||||
welcome: 'time-context e2e ready.'
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext: false
|
||||
@@ -1,29 +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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
|
||||
// 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 driver = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context-driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context.cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
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 })
|
||||
@@ -35,67 +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 proc = spawn(
|
||||
process.execPath,
|
||||
['--expose-internals', '--import', tsxLoader, binScript, configPath],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TZ: 'Asia/Shanghai',
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
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', () => {
|
||||
describe('time-context through a real headless cordis.yml', () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
const { stdout, stderr } = await runTwoTurns()
|
||||
let events: SessionEvent[] = []
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'time-context headless smoke',
|
||||
tempDirPrefix: 'time-context-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
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)
|
||||
},
|
||||
})
|
||||
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')
|
||||
@@ -119,8 +69,7 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
)
|
||||
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header'
|
||||
|| event.type === 'request/header-delta')
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -38,7 +38,7 @@ async function mount(config: Config = {}) {
|
||||
|
||||
function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
return {
|
||||
id: AgentId(id),
|
||||
id: SessionId(id),
|
||||
options: {},
|
||||
session,
|
||||
status: 'running',
|
||||
@@ -83,7 +83,7 @@ async function fire(
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal)
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, signal)
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
@@ -370,7 +370,7 @@ describe('real agent-loop request history', () => {
|
||||
if (mode === 'throws') throw new Error('later pre-step failure')
|
||||
subject.cancel('later pre-step cancellation')
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
@@ -396,7 +396,7 @@ describe('real agent-loop request history', () => {
|
||||
return [{ type: 'text' as const, text: 'advanced' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('loop'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
@@ -423,10 +423,8 @@ describe('real agent-loop request history', () => {
|
||||
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
|
||||
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header'
|
||||
|| event.type === 'request/header-delta')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" }
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../support/loader-smoke" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ 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
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives first because a later tool aborted the step and the loop discarded its context buffer, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
|
||||
@@ -78,11 +78,11 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
|
||||
|
||||
### Baseline session prefix
|
||||
|
||||
**What the model sees**: At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
|
||||
#### Baseline instruction template
|
||||
##### Baseline instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
@@ -98,13 +98,21 @@ Instructions from: AGENTS.md
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token.
|
||||
|
||||
### Newly discovered scope context
|
||||
|
||||
**What the model sees**: After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
|
||||
|
||||
#### Additional instruction template
|
||||
##### Additional instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
@@ -116,13 +124,21 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Changed or removed instruction context
|
||||
|
||||
**What the model sees**: A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
|
||||
A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
|
||||
|
||||
#### Removal notice
|
||||
##### Removal notice
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
@@ -132,6 +148,14 @@ The previously loaded instructions from this file no longer apply.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
|
||||
|
||||
#### 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
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +137,7 @@ function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
pending: Map<string, PendingInstructionChange>,
|
||||
): Map<string, WorkspaceInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes.map(node => node.seq))
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, WorkspaceInstructionChange>()
|
||||
for (const [seq, event] of agent.session.events.entries()) {
|
||||
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
@@ -255,8 +254,8 @@ function invalidateInstructionVersions(
|
||||
/**
|
||||
* Settle provisional tool-result state against durable session events.
|
||||
* A matching context event confirms the transition. If its owning step closes
|
||||
* first, the loop discarded its context buffer, so both duplicate suppression
|
||||
* and the metadata fast path must be re-armed for the next successful touch.
|
||||
* first, both duplicate suppression and the metadata fast path are re-armed for
|
||||
* the next successful touch.
|
||||
* @param session - session whose append-only log emitted `event`.
|
||||
* @param event - newly committed session event.
|
||||
* @param pendingBySession - provisional transitions awaiting log confirmation.
|
||||
|
||||
@@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -44,12 +44,11 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('workspace-context-e2e'),
|
||||
sessionId: SessionId('workspace-context-e2e-session'),
|
||||
meta: { cwd: workdir },
|
||||
agentOptions: { model: 'deepseek-v4-flash' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
return { ctx, agent: handle.agent }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -163,7 +163,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
|
||||
return {
|
||||
ctx: new Context(),
|
||||
id: AgentId('a1'),
|
||||
id: SessionId('a1'),
|
||||
options: {},
|
||||
session,
|
||||
status: 'idle',
|
||||
@@ -173,7 +173,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' })
|
||||
},
|
||||
@@ -202,7 +201,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,
|
||||
@@ -217,7 +215,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
|
||||
}
|
||||
@@ -1562,7 +1559,7 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
describe('dynamic nested workspace context injection', () => {
|
||||
it('re-arms a buffered instruction change when a later tool aborts the step before context append', async () => {
|
||||
it('commits a buffered instruction change before a later tool abort closes the step', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
@@ -1591,7 +1588,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { model: 'mock' }, { cwd: root })
|
||||
const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'abort_step',
|
||||
description: 'Abort the current test step.',
|
||||
@@ -1604,12 +1601,14 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
agent.send([{ type: 'text', text: 'read and abort' }])
|
||||
await agent.whenIdle()
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
|
||||
|
||||
agent.send([{ type: 'text', text: 'retry the read' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
const contexts = agent.session.events.filter(event => event.type === 'context/message')
|
||||
// The aborted batch drained its accepted context before step close, so the
|
||||
// retry sees durable history without producing a duplicate instruction.
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
|
||||
@@ -1693,7 +1692,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,
|
||||
@@ -2459,7 +2457,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' }],
|
||||
@@ -2472,7 +2469,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 })
|
||||
@@ -2805,7 +2803,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)
|
||||
@@ -2815,7 +2812,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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# packages/cordis — the self-referential runtime toolset
|
||||
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset RFC](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## What it does
|
||||
|
||||
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
|
||||
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
|
||||
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
|
||||
- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
|
||||
|
||||
@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -22,7 +22,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
|
||||
|
||||
## The generated API catalog
|
||||
|
||||
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time.
|
||||
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
@@ -36,21 +36,45 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Fixed schema cost on every request in that tool view.
|
||||
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request in that tool view.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
**What the model sees**: Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
|
||||
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Later requests after a mount
|
||||
|
||||
**What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
|
||||
A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,9 @@ import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { STATE_LABELS } from './fiber-state.ts'
|
||||
import { isPlugin, pluginName } from './guard.ts'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
|
||||
import { missingServices, mountDynamic } from './mount.ts'
|
||||
import type { DynamicMount } from './mount.ts'
|
||||
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
|
||||
import { createSandbox, evaluateMountCode } from './sandbox.ts'
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Config {
|
||||
/**
|
||||
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
|
||||
* before evaluation is aborted (default 5000). An async body escapes this
|
||||
* bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
|
||||
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
|
||||
*/
|
||||
vmTimeoutMs?: number
|
||||
}
|
||||
@@ -63,15 +63,23 @@ export function apply(ctx: Context, config: Config): void {
|
||||
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
|
||||
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
|
||||
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
|
||||
+ 'Omit `what` to get all six sections.',
|
||||
+ 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` '
|
||||
+ 'to narrow to one service/event and include its original source JSDoc.',
|
||||
parameters: {
|
||||
what: {
|
||||
type: 'string',
|
||||
enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
|
||||
description: 'Limit the report to one section. Omit for all sections.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
|
||||
},
|
||||
},
|
||||
execute(args, exec): Promise<{ type: 'text'; text: string }[]> {
|
||||
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
|
||||
throw new Error('name is valid only with what:"api" or what:"events"')
|
||||
}
|
||||
const sections: [heading: string, body: () => string[]][] = [
|
||||
['services', () => describeServices(ctx)],
|
||||
['plugins', () => describePlugins(ctx)],
|
||||
@@ -79,8 +87,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// globals absent — "what you can call", not the global registry.
|
||||
['tools', () => describeTools(ctx, exec.agent)],
|
||||
['dynamic', () => describeDynamic(ctx, mounts)],
|
||||
['api', () => describeApi(ctx)],
|
||||
['events', () => describeEvents()],
|
||||
['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
|
||||
['events', () => describeEvents(EVENT_API, args.name)],
|
||||
]
|
||||
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
|
||||
const text = selected
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
|
||||
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
|
||||
* and the catalog-backed `api` / `events` sections.
|
||||
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
|
||||
* original source JSDoc without inflating the default reports.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
@@ -126,6 +127,18 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
|
||||
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Render one catalogued service, optionally including source-owned method JSDoc. */
|
||||
function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
|
||||
const lines = [`- ${entry.key} — ${entry.summary}`]
|
||||
for (const method of entry.methods) {
|
||||
if (detailed) {
|
||||
for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`)
|
||||
}
|
||||
lines.push(` ${method.signature}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated catalog against the live runtime: live catalogued services with methods,
|
||||
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
|
||||
@@ -134,6 +147,7 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include original JSDoc; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
@@ -141,25 +155,33 @@ export function describeApi(
|
||||
api: readonly ServiceApiEntry[] = SERVICE_API,
|
||||
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
|
||||
types: readonly TypeApiEntry[] = TYPE_API,
|
||||
name?: string,
|
||||
): string[] {
|
||||
const live = new Map<string, string>()
|
||||
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
|
||||
const lines: string[] = []
|
||||
const liveMethodTexts: string[] = []
|
||||
for (const entry of api) {
|
||||
if (!live.has(entry.key)) continue
|
||||
lines.push(`- ${entry.key} — ${entry.summary}`)
|
||||
let selected = api.filter(entry => live.has(entry.key))
|
||||
if (name !== undefined) {
|
||||
const entry = api.find(candidate => candidate.key === name)
|
||||
if (!entry) throw new Error(`no catalogued service named "${name}"`)
|
||||
if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [entry]
|
||||
}
|
||||
for (const entry of selected) {
|
||||
lines.push(...serviceLines(entry, name !== undefined))
|
||||
for (const method of entry.methods) {
|
||||
lines.push(` ${method}`)
|
||||
liveMethodTexts.push(method)
|
||||
liveMethodTexts.push(method.signature)
|
||||
}
|
||||
}
|
||||
const catalogued = new Set(api.map(entry => entry.key))
|
||||
for (const [name, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!catalogued.has(name)) lines.push(`- ${name} (provided by ${fiber}, no catalog entry)`)
|
||||
if (name === undefined) {
|
||||
const catalogued = new Set(api.map(entry => entry.key))
|
||||
for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`)
|
||||
}
|
||||
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
}
|
||||
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
const shapes = typeClosure(liveMethodTexts, types)
|
||||
if (shapes.length > 0) {
|
||||
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
|
||||
@@ -167,8 +189,10 @@ export function describeApi(
|
||||
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
|
||||
}
|
||||
}
|
||||
lines.push('inherited ctx API:')
|
||||
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
|
||||
if (name === undefined) {
|
||||
lines.push('inherited ctx API:')
|
||||
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -176,13 +200,24 @@ export function describeApi(
|
||||
* The `events` section: every harness event with its dispatch mode, one-line
|
||||
* summary, and exact signature, closed by the waterfall caution.
|
||||
* @param events - the event catalog (the generated one by default; injectable for tests).
|
||||
* @param name - exact event name whose signature should include original JSDoc; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API): string[] {
|
||||
const lines = events.flatMap(event => [
|
||||
`- ${event.name} [${event.mode}] — ${event.summary}`,
|
||||
` ${event.signature}`,
|
||||
])
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
|
||||
let selected = events
|
||||
if (name !== undefined) {
|
||||
const event = events.find(candidate => candidate.name === name)
|
||||
if (!event) throw new Error(`no catalogued event named "${name}"`)
|
||||
selected = [event]
|
||||
}
|
||||
const lines = selected.flatMap((event) => {
|
||||
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
|
||||
if (name !== undefined) {
|
||||
for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`)
|
||||
}
|
||||
entry.push(` ${event.signature}`)
|
||||
return entry
|
||||
})
|
||||
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.')
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic card the ACP bridge renders.
|
||||
*/
|
||||
export function presentInspectCall(args: { what?: string }): GenericCallView {
|
||||
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: args.what === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${args.what}`,
|
||||
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}]`
|
||||
|
||||
@@ -64,6 +64,24 @@ describe('cordis_inspect', () => {
|
||||
// The inherited ctx surface closes the section.
|
||||
expect(report).toContain('inherited ctx API:')
|
||||
expect(report).toContain('- ctx.effect — ')
|
||||
// The broad report stays compact; exact-name lookup owns full JSDoc.
|
||||
expect(report).not.toContain('/**')
|
||||
expect(report).not.toContain('@param definition')
|
||||
})
|
||||
|
||||
it('adds original method JSDoc only for an exact live api name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' }))
|
||||
expect(report).toContain('## api')
|
||||
expect(report).toContain('- tools — Tool registry and execution pipeline.')
|
||||
expect(report).toContain('/**')
|
||||
expect(report).toContain('Register globally or in the calling agent scope.')
|
||||
expect(report).toContain('@param definition - the tool schema')
|
||||
expect(report).toContain('@returns the exact disposer')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).not.toContain('not running (loadable services')
|
||||
expect(report).not.toContain('inherited ctx API:')
|
||||
})
|
||||
|
||||
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
|
||||
@@ -73,6 +91,39 @@ describe('cordis_inspect', () => {
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toMatch(/'agent\/status'\(/)
|
||||
expect(report).toContain('returning without next() vetoes the chain')
|
||||
expect(report).not.toContain('/**')
|
||||
expect(report).not.toContain('@mode waterfall')
|
||||
})
|
||||
|
||||
it('adds original event JSDoc only for an exact event name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' }))
|
||||
expect(report).toContain('## events')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toContain('/**')
|
||||
expect(report).toContain('Allow, deny, or ask before dispatch.')
|
||||
expect(report).toContain('@param exec - the pending call')
|
||||
expect(report).toContain('@mode waterfall')
|
||||
expect(report).not.toContain('- tools/change [emit]')
|
||||
})
|
||||
|
||||
it('fails loud for incompatible, unknown, and non-running names', async () => {
|
||||
const ctx = await setup()
|
||||
const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' })
|
||||
expect(incompatible.isError).toBe(true)
|
||||
expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"')
|
||||
|
||||
const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' })
|
||||
expect(unknownService.isError).toBe(true)
|
||||
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
|
||||
|
||||
const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'bash' })
|
||||
expect(nonRunning.isError).toBe(true)
|
||||
expect(text(nonRunning)).toContain('catalogued service "bash" is not running')
|
||||
|
||||
const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' })
|
||||
expect(unknownEvent.isError).toBe(true)
|
||||
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +153,11 @@ describe('inspect renderers (direct)', () => {
|
||||
|
||||
it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
|
||||
const ctx = await setup()
|
||||
const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], [])
|
||||
const lines = describeApi(ctx, [{
|
||||
key: 'tools',
|
||||
summary: 'The registry.',
|
||||
methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }],
|
||||
}], [], [])
|
||||
expect(lines[0]).toBe('- tools — The registry.')
|
||||
expect(lines[1]).toBe(' register(x): void')
|
||||
expect(lines.join('\n')).not.toContain('not running')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as ToolCordis from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -24,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -44,7 +45,7 @@ describe('cordis tools through the agent loop', () => {
|
||||
textResponse('Done.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -175,9 +175,13 @@ describe('cordis_mount', () => {
|
||||
// The registered schema is canonical JSON Schema derived from the DSL:
|
||||
// the required array survived, integer became number, extra is optional.
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
|
||||
const parameters = schema.parameters as { properties: Record<string, { type: string; enum?: string[] }>; required?: string[] }
|
||||
const parameters = schema.parameters as {
|
||||
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(parameters.required).toEqual(['text'])
|
||||
expect(parameters.properties.count!.type).toBe('number')
|
||||
expect(parameters.properties.count!.default).toBe(1)
|
||||
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
|
||||
// Arg validation enforces the normalized spec: text required, extra not.
|
||||
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
|
||||
|
||||
@@ -11,6 +11,11 @@ describe('presenters', () => {
|
||||
it('cordis_inspect renders a generic read card titled with the section', () => {
|
||||
expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
|
||||
expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
|
||||
expect(presentInspectCall({ what: 'events', name: 'tools/change' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: events: tools/change',
|
||||
})
|
||||
})
|
||||
|
||||
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
|
||||
@@ -33,6 +38,9 @@ describe('presenters', () => {
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
|
||||
title: 'Inspect cordis runtime: api: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
|
||||
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
|
||||
// Soft validation: presenter args that fail the schema render as no card, never a throw.
|
||||
|
||||
@@ -32,8 +32,9 @@ describe('tool registration', () => {
|
||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
||||
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
|
||||
const props = (inspect.parameters as { properties: Record<string, { enum?: string[] }> }).properties
|
||||
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
|
||||
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
|
||||
expect(props.name?.type).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
|
||||
|
||||
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-agent-loop
|
||||
|
||||
Concrete `ReactLoopAgent` implementation and loop driver.
|
||||
THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle.
|
||||
|
||||
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
|
||||
|
||||
@@ -8,16 +8,18 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md).
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
|
||||
Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach.
|
||||
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
|
||||
|
||||
- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy.
|
||||
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted.
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
@@ -29,8 +31,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
||||
|
||||
```ts
|
||||
interface Config {
|
||||
maxParallelToolCalls?: number // default 10; 1 is serial
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
provider?: string
|
||||
model?: string
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
@@ -38,25 +42,30 @@ interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Configured agents start automatically. `cwd` applies only to fresh sessions; `resumeSessionId` retains persisted metadata. They use the deployment persona. Programmatic setup can shadow it per agent. This plugin supplies the per-agent `model` and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
|
||||
### Exported concrete class
|
||||
### Internal concrete driver
|
||||
|
||||
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. 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.
|
||||
|
||||
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
|
||||
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`)
|
||||
|
||||
The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures.
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. 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 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.
|
||||
|
||||
### What belongs to plugins
|
||||
|
||||
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: `agent/pre-step`
|
||||
- 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`
|
||||
@@ -66,19 +75,49 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
||||
|
||||
### Complete conversation request
|
||||
|
||||
**What the model sees**: For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
|
||||
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
|
||||
|
||||
#### Token effect
|
||||
|
||||
System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
|
||||
|
||||
### Retained message history
|
||||
|
||||
**What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
|
||||
Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token.
|
||||
|
||||
### Undispatched calls after cancellation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One fixed error result per skipped call remains in history until compaction shadows it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`).
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentId, AgentOptions, AgentStatus, InjectOptions, SendOptions } 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 } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
@@ -54,15 +54,20 @@ export interface PreparedReactLoopAgent {
|
||||
* @param id - the concrete agent identity.
|
||||
* @param options - loop options for the agent.
|
||||
* @param session - the prepared session the agent will own.
|
||||
* @param maxParallelToolCalls - resolved in-flight cap for this agent.
|
||||
* @returns the agent and closures bound only to that exact instance.
|
||||
*/
|
||||
export function prepareReactLoopAgent(
|
||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
|
||||
ctx: Context,
|
||||
id: SessionId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
maxParallelToolCalls: number,
|
||||
): PreparedReactLoopAgent {
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls)
|
||||
claimedDriverSessions.add(session)
|
||||
const dispose = () => agent[stopDriver]()
|
||||
return {
|
||||
@@ -75,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
|
||||
@@ -143,19 +147,27 @@ export class ReactLoopAgent implements Agent {
|
||||
* the `disposed` transition fires and leave the promise hanging.
|
||||
*/
|
||||
private idleWaiters: (() => void)[] = []
|
||||
/** Maximum parallel-safe calls allowed in one step. */
|
||||
private readonly maxParallelToolCalls: number
|
||||
/**
|
||||
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
|
||||
* synchronous, so it cannot await them itself; the driver disposer drains
|
||||
* this set before the lifecycle unregisters the agent or detaches its session.
|
||||
*/
|
||||
private pendingIdleFlushes = new Set<Promise<void>>()
|
||||
/** Whether the current step is executing an assistant tool-call batch. */
|
||||
private toolBatchActive = false
|
||||
/** Open-turn injections waiting for the active assistant tool-call batch to close. */
|
||||
private deferredInjections: HookContext[] = []
|
||||
|
||||
constructor(
|
||||
private loopCtx: Context,
|
||||
public readonly id: AgentId,
|
||||
public readonly id: SessionId,
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
maxParallelToolCalls: number,
|
||||
) {
|
||||
this.maxParallelToolCalls = maxParallelToolCalls
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.disposed = promise
|
||||
this.resolveDisposed = resolve
|
||||
@@ -189,12 +201,11 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one public send/steer payload as the exact detached record shared by
|
||||
* the live notification and inbox. Lossless-JSON materialization reads every
|
||||
* nested field once; deep freeze prevents an observer from rewriting queued
|
||||
* work before the loop drains it.
|
||||
* Accept one public message payload as a detached record. Lossless-JSON
|
||||
* materialization reads every nested field once; deep freeze prevents later
|
||||
* caller mutation before an inbox or deferred-injection queue drains it.
|
||||
*/
|
||||
private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
|
||||
private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
|
||||
const source = this.resolveSource(options)
|
||||
const accepted = snapshotJsonValue({ content, source })
|
||||
if (accepted === undefined) {
|
||||
@@ -203,6 +214,15 @@ export class ReactLoopAgent implements Agent {
|
||||
return deepFreeze(accepted)
|
||||
}
|
||||
|
||||
/** Detach one context before it can outlive its caller in the active-batch FIFO. */
|
||||
private acceptContext(context: HookContext): HookContext {
|
||||
const accepted = snapshotJsonValue(context)
|
||||
if (accepted === undefined) {
|
||||
throw new TypeError('agent context must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(accepted)
|
||||
}
|
||||
|
||||
/** Reject a driving operation once teardown has synchronously closed the agent. */
|
||||
private assertNotDisposed(): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
@@ -210,7 +230,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.enqueue(accepted)
|
||||
const info = { source: accepted.source, steering: false } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
@@ -219,7 +239,7 @@ export class ReactLoopAgent implements Agent {
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.steer(accepted)
|
||||
const info = { source: accepted.source, steering: true } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
@@ -231,14 +251,18 @@ 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)) {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', context, { surfaceOp: 'append' })
|
||||
const accepted = this.acceptContext(context)
|
||||
// Provider protocols require every assistant tool-call batch to be
|
||||
// followed only by its tool results. Historical interrupted batches do
|
||||
// not own new context; only the currently executing batch may defer it.
|
||||
if (this.toolBatchActive) {
|
||||
this.deferredInjections.push(accepted)
|
||||
return
|
||||
}
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
@@ -265,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)
|
||||
@@ -278,6 +302,34 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Append deferred open-turn injections after the loop closes a tool-result batch. */
|
||||
private drainDeferredInjections(): void {
|
||||
const pending = this.deferredInjections.splice(0)
|
||||
for (const accepted of pending) {
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one tool-call batch and drain its deferred context before settlement.
|
||||
* The loop-owned acceptor remains valid after public disposal begins because
|
||||
* the interrupted turn stays open until this batch settles.
|
||||
*/
|
||||
private async withToolBatch<T>(
|
||||
run: (acceptContext: (context: HookContext) => void) => Promise<T>,
|
||||
): Promise<T> {
|
||||
this.toolBatchActive = true
|
||||
const acceptContext = (context: HookContext): void => {
|
||||
this.deferredInjections.push(this.acceptContext(context))
|
||||
}
|
||||
try {
|
||||
return await run(acceptContext)
|
||||
} finally {
|
||||
this.toolBatchActive = false
|
||||
this.drainDeferredInjections()
|
||||
}
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
// Arm only for current work; an idle marker would cancel the next prompt.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
@@ -333,8 +385,9 @@ export class ReactLoopAgent implements Agent {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, {
|
||||
inbox: this.#inbox,
|
||||
maxParallelToolCalls: this.maxParallelToolCalls,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.disposed,
|
||||
@@ -342,9 +395,10 @@ export class ReactLoopAgent implements Agent {
|
||||
isCancelled: () => this.cancelRequested,
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
// Pre-step cancellation re-parks without emitting a status transition.
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-start cancellation settles queued-work waiters before publishing idle.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,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)
|
||||
}
|
||||
|
||||
6
packages/core/agent-loop/src/constants.ts
Normal file
6
packages/core/agent-loop/src/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** Shared agent-loop scheduler defaults.
|
||||
* @module dsh-agent-loop/constants
|
||||
*/
|
||||
|
||||
/** Default maximum in-flight parallel-safe calls per agent step. */
|
||||
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10
|
||||
@@ -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
|
||||
|
||||
@@ -12,15 +12,15 @@ import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent,
|
||||
AgentFactory,
|
||||
AgentHandle,
|
||||
AgentId,
|
||||
AgentOptions,
|
||||
CreateAgentOptions,
|
||||
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'
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
ReactLoopAgent,
|
||||
} from './agent.ts'
|
||||
import type { PreparedReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
|
||||
|
||||
/** Fiber states that cannot own or serve a new lifecycle. */
|
||||
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
@@ -45,7 +44,9 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
/** Factory-level ownership of every preparing or live transaction. */
|
||||
class FactoryOwnership {
|
||||
private accepting = true
|
||||
private readonly inactive = Promise.withResolvers<void>()
|
||||
private transactions = new Set<AgentCreationTransaction>()
|
||||
private startupTasks = new Set<Promise<void>>()
|
||||
|
||||
constructor(private readonly fiber: Context['fiber']) {}
|
||||
|
||||
@@ -58,21 +59,44 @@ class FactoryOwnership {
|
||||
return () => { this.transactions.delete(transaction) }
|
||||
}
|
||||
|
||||
/** Join config startup work that begins before an agent transaction exists. */
|
||||
trackStartup(task: Promise<void>): void {
|
||||
this.startupTasks.add(task)
|
||||
const forget = () => { this.startupTasks.delete(task) }
|
||||
void task.then(forget, forget)
|
||||
}
|
||||
|
||||
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
||||
async waitWhileActive(task: Promise<void>): Promise<void> {
|
||||
await Promise.race([task, this.inactive.promise])
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.accepting = false
|
||||
this.inactive.resolve()
|
||||
const reason = new Error('agent loop is not active')
|
||||
await Promise.all(
|
||||
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
)
|
||||
await Promise.all([
|
||||
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
...this.startupTasks,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the public cancellation error while preserving a caller-supplied cause. */
|
||||
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
|
||||
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
}
|
||||
|
||||
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
||||
function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
|
||||
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
|
||||
throw new Error('maxParallelToolCalls must be a positive integer')
|
||||
}
|
||||
return maxParallelToolCalls
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-owned create/resume transaction through rollback-covered publication
|
||||
* and quiescent teardown. Resources remain private until the final registry
|
||||
@@ -105,7 +129,7 @@ class AgentCreationTransaction {
|
||||
private readonly loopCtx: Context,
|
||||
private readonly ownerCtx: Context,
|
||||
private readonly ownership: FactoryOwnership,
|
||||
readonly id: AgentId,
|
||||
readonly id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
ownerCtx.fiber.assertActive()
|
||||
@@ -163,13 +187,13 @@ class AgentCreationTransaction {
|
||||
}
|
||||
|
||||
/** Construct the driver and scope, then install their complete ordered lifecycle. */
|
||||
prepare(options: AgentOptions, session: Session): ReactLoopAgent {
|
||||
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
|
||||
this.assertActive()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
this.preparing = gate.promise
|
||||
try {
|
||||
this.session = session
|
||||
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session)
|
||||
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
|
||||
this.driver = driver
|
||||
const agent = driver.agent
|
||||
const scope = createScope(this.loopCtx, agent)
|
||||
@@ -227,7 +251,7 @@ class AgentCreationTransaction {
|
||||
this.publishing = true
|
||||
try {
|
||||
this.detachSession = agent.ctx.sessions.enter(session)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
|
||||
|
||||
agent.ctx.sessions.announce(session)
|
||||
this.assertActive()
|
||||
@@ -316,14 +340,35 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* A declarative agent entry failed before it could publish a live agent.
|
||||
* Consumers that buffer work for the configured identity use this
|
||||
* transient signal to reject that work instead of waiting forever. Normal
|
||||
* factory teardown suppresses failures from the cancelled startup attempt.
|
||||
* @param sessionId - exact shared agent/session identity that failed startup.
|
||||
* @param error - persistence, setup, or publication failure.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin configuration for declarative startup agents. */
|
||||
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
||||
|
||||
/** Agent-loop plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
|
||||
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
||||
*/
|
||||
maxParallelToolCalls?: number
|
||||
/** Agents created or resumed at plugin startup. */
|
||||
agents: (AgentOptions & {
|
||||
/** Registry identity for the live agent. */
|
||||
id: AgentId
|
||||
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||
id: string
|
||||
/** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
|
||||
sessionId?: SessionId
|
||||
/** Optional workspace for a fresh session. */
|
||||
cwd?: string
|
||||
/** Persisted session to resume instead of creating a fresh session. */
|
||||
@@ -331,14 +376,35 @@ export interface Config {
|
||||
})[]
|
||||
}
|
||||
|
||||
/** Concrete ReactLoopAgent factory and driver service. */
|
||||
/** Reject self-contained identity conflicts before any configured agent starts. */
|
||||
function validateConfiguredAgents(agents: Config['agents']): void {
|
||||
const exactIdentities = new Map<SessionId, string>()
|
||||
for (const { id, sessionId, resumeSessionId } of agents) {
|
||||
const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
|
||||
if (sessionId !== undefined && hasResumeId) {
|
||||
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
|
||||
}
|
||||
const exactIdentity = hasResumeId ? resumeSessionId : sessionId
|
||||
if (exactIdentity === undefined) continue
|
||||
const firstId = exactIdentities.get(exactIdentity)
|
||||
if (firstId !== undefined) {
|
||||
throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
|
||||
}
|
||||
exactIdentities.set(exactIdentity, id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete agent factory and driver service. */
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
/** Runtime schema for declarative agents. */
|
||||
static Config = z.object({
|
||||
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
sessionId: z.string().min(1),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
@@ -346,31 +412,45 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}) as unknown as z<Config>
|
||||
|
||||
private readonly ownership: FactoryOwnership
|
||||
/** Resolved concurrency cap for every driver created by this factory. */
|
||||
private readonly maxParallelToolCalls: number
|
||||
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
||||
private readonly runtime: { ctx: Context }
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
validateConfiguredAgents(config.agents)
|
||||
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
|
||||
this.ownership = new FactoryOwnership(ctx.fiber)
|
||||
this.runtime = { ctx }
|
||||
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
|
||||
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
const meta = cwd === undefined ? {} : { cwd }
|
||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
|
||||
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
this.create(configuredId, options, meta)
|
||||
} else {
|
||||
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
|
||||
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
|
||||
})
|
||||
this.ownership.trackStartup(startup)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
}).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
|
||||
})
|
||||
})
|
||||
return fiber.dispose
|
||||
@@ -378,21 +458,84 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a contained declarative-start failure to identity-bound consumers. */
|
||||
private reportConfiguredStartupFailure(
|
||||
configId: string,
|
||||
action: 'restore' | 'resume',
|
||||
sessionId: SessionId,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!this.ownership.isActive()) return
|
||||
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: ${errorChain(listenerError)}`)
|
||||
})
|
||||
} catch (listenerError: unknown) {
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a materialized exact config identity on remount, or create it on first use. */
|
||||
private async restoreOrCreateConfigured(
|
||||
ownerCtx: Context,
|
||||
persistence: SessionPersistence,
|
||||
sessionId: SessionId,
|
||||
agentOptions: AgentOptions,
|
||||
meta: Pick<SessionHeader, 'cwd'>,
|
||||
): Promise<void> {
|
||||
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
|
||||
if (!this.ownership.isActive()) return
|
||||
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
||||
if (!this.ownership.isActive()) return
|
||||
if (exists) {
|
||||
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
|
||||
return
|
||||
}
|
||||
this.create(sessionId, agentOptions, meta)
|
||||
}
|
||||
|
||||
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
|
||||
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
|
||||
const current = ownerCtx.agents.get(sessionId)
|
||||
if (current?.status !== 'disposed') return
|
||||
|
||||
const released = Promise.withResolvers<void>()
|
||||
const checkReleased = (): void => {
|
||||
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
|
||||
released.resolve()
|
||||
}
|
||||
}
|
||||
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased)
|
||||
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
|
||||
try {
|
||||
checkReleased()
|
||||
await this.ownership.waitWhileActive(released.promise)
|
||||
} finally {
|
||||
disposeAgentListener()
|
||||
disposeSessionListener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent on a fresh per-run session, owned by the accessing fiber.
|
||||
* Constructor-driven config calls use the loop fiber itself.
|
||||
* @param id - agent registry id.
|
||||
* Create an agent and session under one caller-supplied identity, owned by
|
||||
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
||||
* id before entering this boundary.
|
||||
* @param id - shared agent/session identity.
|
||||
* @param options - concrete loop options.
|
||||
* @param meta - optional fresh-session workspace metadata.
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
||||
const loopCtx = this.runtime.ctx
|
||||
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
||||
try {
|
||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
||||
const session = loopCtx.sessions.prepare(sessionId, { meta })
|
||||
const agent = transaction.prepare(options, session)
|
||||
const session = loopCtx.sessions.prepare(id, { meta })
|
||||
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
|
||||
transaction.publish('startup')
|
||||
return agent
|
||||
} catch (error: unknown) {
|
||||
@@ -410,11 +553,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published handle.
|
||||
*/
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const agentOptions = options.agentOptions ?? {}
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.sessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
@@ -422,7 +566,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = transaction.prepare(options.agentOptions ?? {}, session)
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('startup')
|
||||
@@ -454,11 +598,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
persistence: SessionPersistence,
|
||||
options: ResumeAgentOptions,
|
||||
): Promise<AgentHandle> {
|
||||
const agentOptions = options.agentOptions ?? {}
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.resumeSessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
@@ -466,14 +611,9 @@ 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(options.agentOptions ?? {}, session)
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('resume')
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Drives one agent across queued durable turns. Turn failures are contained so
|
||||
* later work can run; the session log, not this driver, owns conversation state.
|
||||
* See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } 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, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -17,29 +18,39 @@ import type { TransmissionLog } from './request-log.ts'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
function toError(error: unknown): CodedError {
|
||||
function toError(error: unknown): RequestError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/** Distinguishes final model-request failures from failures in later step processing. */
|
||||
class TerminalModelRequestFailure extends Error {
|
||||
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): CodedError | undefined {
|
||||
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error: CodedError = new Error(finish.message)
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error: CodedError = 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:
|
||||
@@ -50,9 +61,18 @@ function finishError(finish: FinishReason): CodedError | 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: CodedError): { message: string; code?: string } {
|
||||
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
function errorData(err: RequestError): { message: string; code?: string } {
|
||||
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. */
|
||||
@@ -72,6 +92,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
/** Maximum parallel-safe calls allowed in one step. */
|
||||
readonly maxParallelToolCalls: number
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
@@ -83,18 +105,25 @@ 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.
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* 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)
|
||||
* through.
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
* @throws when no initiating Agent is active.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
// Per-instance prefix and request-header state; conversation history remains in the session log.
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
@@ -103,20 +132,35 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
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.
|
||||
@@ -132,11 +176,11 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission)
|
||||
} 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 */ }
|
||||
@@ -155,19 +199,27 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
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 requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
@@ -180,10 +232,12 @@ async function runTurn(
|
||||
}
|
||||
|
||||
// Record the durable turn failure once and contain the live error notification.
|
||||
const failTurn = (err: CodedError): 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 {
|
||||
@@ -202,61 +256,41 @@ 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
|
||||
// the request.
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
drainSteering()
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -266,7 +300,7 @@ async function runTurn(
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Assemble once before pre-step so pressure checks and the request share the same prompt.
|
||||
// Assemble once before pre-step so listener work and the request share one prompt value.
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
@@ -277,9 +311,9 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// Compose the request-only prefix once per loop instance before pressure
|
||||
// checks. It precedes all derived history and is recorded only in the
|
||||
// request header, not as session history.
|
||||
// Compose the request-only prefix once per loop instance before the first
|
||||
// request boundary. It precedes all derived history and is recorded only
|
||||
// in the request header, not as session history.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
@@ -296,8 +330,8 @@ async function runTurn(
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Await surface mutations outside the step; pressure checks receive the pending prefix.
|
||||
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||
// Await surface mutations outside the step before snapshotting history.
|
||||
await events.serial('agent/pre-step', turn, step, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
@@ -327,14 +361,69 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
let stepOutcome:
|
||||
| { hadToolCalls: boolean; finish: FinishReason }
|
||||
| { requestError: RequestError; failure: LlmFailure }
|
||||
| { error: RequestError }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
} finally {
|
||||
if (error instanceof TerminalModelRequestFailure) {
|
||||
stepOutcome = { requestError: error.requestError, failure: error.failure }
|
||||
} else {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
if ('requestError' in stepOutcome) {
|
||||
// Recovery observes a balanced failed step and the original provider
|
||||
// error while the failed step's signal remains the active owner.
|
||||
closeStep()
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
break
|
||||
}
|
||||
|
||||
const defaultDecision: RequestErrorDecision = { action: 'fail' }
|
||||
let recoveryDecision: RequestErrorDecision = defaultDecision
|
||||
try {
|
||||
recoveryDecision = await events.waterfall(
|
||||
'agent/request-error', turn, step, stepOutcome.requestError,
|
||||
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}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
handle.setAbort(undefined)
|
||||
|
||||
// Cancellation and disposal always win over either a recovery decision
|
||||
// or a recovery-listener failure.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
break
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
case 'retry':
|
||||
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
|
||||
continue
|
||||
case 'fail':
|
||||
failTurn(stepOutcome.requestError, stepOutcome.failure)
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(recoveryDecision, 'agent request-error decision')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
@@ -342,7 +431,9 @@ async function runTurn(
|
||||
// runLoop re-enqueues it as a queued message, so an abort-then-steer
|
||||
// starts a fresh turn instead of being silently consumed.
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
const { error } = stepOutcome
|
||||
/* v8 ignore next -- narrow race: disposal while non-request step work throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
@@ -354,14 +445,47 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
requestFailureHistory = Object.freeze([])
|
||||
|
||||
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
const steered = drainSteering()
|
||||
|
||||
try {
|
||||
await events.serial('agent/post-step', turn, step, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
/* v8 ignore next -- narrow race: disposal while a post-step listener throws. */
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- signal.reason always set by cancellation or disposal. */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
} else {
|
||||
failTurn(stepOutcome.error)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (handle.isDisposed() || abort.signal.aborted) {
|
||||
reason = handle.isDisposed()
|
||||
? { kind: 'disposed' }
|
||||
: { kind: 'aborted', reason: String(abort.signal.reason) }
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
break
|
||||
}
|
||||
|
||||
closeStep()
|
||||
handle.setAbort(undefined)
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
@@ -438,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 {
|
||||
@@ -448,15 +572,6 @@ async function runTurn(
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one committed step: transform call config, log the request header, build
|
||||
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
||||
@@ -466,7 +581,7 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
agent: ReactLoopAgent,
|
||||
handle: LoopHandle,
|
||||
turn: number,
|
||||
step: number,
|
||||
assembly: PromptAssembly,
|
||||
@@ -475,6 +590,7 @@ async function runStep(
|
||||
transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session, options } = agent
|
||||
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
@@ -482,12 +598,12 @@ async function runStep(
|
||||
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
|
||||
? session.requestHeader()!.config
|
||||
: { model: options.model ?? '' }))
|
||||
: { provider: options.provider ?? '', model: options.model ?? '' }))
|
||||
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
|
||||
if (!config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
if (!config.provider || !config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||
@@ -504,6 +620,7 @@ async function runStep(
|
||||
|
||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
provider: header.config.provider,
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
@@ -518,98 +635,97 @@ async function runStep(
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
for await (const chunk of ctx.llm.stream(request)) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
const stream = ctx.llm.stream(request)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
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 stepError
|
||||
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
|
||||
|
||||
const recordAssistantMessage = (
|
||||
assembledContent: ContentBlock[],
|
||||
message: Message,
|
||||
preserveReplayState = true,
|
||||
): void => {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
content: message.content,
|
||||
provenance: assistantProvenance(
|
||||
header.config,
|
||||
assembler.replayState,
|
||||
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
||||
),
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
|
||||
// A rejected result still records the successful provider call without retaining rejected output.
|
||||
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
|
||||
try {
|
||||
return await events.waterfall(
|
||||
'agent/step-result', turn, step, message, () => Promise.resolve(message),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
let message: Message = withoutToolCalls(assembler.message())
|
||||
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = withoutToolCalls(assembled)
|
||||
message = withoutToolCalls(await processStepResult(assembledContent, message))
|
||||
// Preserve usage even when max-token truncation produced no content.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
// The finish chunk guarantees non-empty provenance here.
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
// Record the post-waterfall message that tool dispatch uses.
|
||||
let message: Message = assembler.message()
|
||||
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = assembled
|
||||
message = await processStepResult(assembledContent, message)
|
||||
|
||||
// Empty messages exist only to carry usage; omit empty provenance.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
|
||||
)
|
||||
}
|
||||
// Every successful call records its completion anchor, including explicit
|
||||
// empty chunk provenance for a contentless, usage-less provider response.
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
|
||||
// Tool execution stays sequential; recheck abort around each normalized result.
|
||||
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
// Buffer context until all results are appended to preserve call/result adjacency.
|
||||
const pendingContext: HookContext[] = []
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
let parsedArguments: unknown
|
||||
try {
|
||||
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
|
||||
} catch {
|
||||
parsedArguments = call.arguments
|
||||
}
|
||||
// TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned;
|
||||
// see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md.
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
arguments: parsedArguments,
|
||||
agent,
|
||||
signal,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
// Correlation comes from the immutable execution input; the result does
|
||||
// not duplicate this authoritative transcript identity.
|
||||
callId: call.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
// Persist tool-owned presentation data for replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
pendingContext.push(...result.additionalContexts ?? [])
|
||||
// The signal may flip while the tool is awaited.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
|
||||
return handle.withToolBatch(async (acceptContext) => {
|
||||
await executeToolCalls(
|
||||
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
)
|
||||
return { hadToolCalls: true, finish: assembler.finish }
|
||||
})
|
||||
}
|
||||
|
||||
// Append buffered context after the complete result batch.
|
||||
for (const context of pendingContext) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
|
||||
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
|
||||
return {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
...contentUnchanged && replayState !== undefined ? { replayState } : {},
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
function withoutToolCalls(message: Message): Message {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Per-loop-instance request-header bookkeeping for reconstructability. The
|
||||
* comparison baseline is the header folded from the session log, so a fresh
|
||||
* loop instance needs no special resume or fork state.
|
||||
* comparison baseline is folded from the session log; a fresh instance anchors
|
||||
* it with an initial/resume snapshot and later logs full changed snapshots.
|
||||
*
|
||||
* @module dsh-agent-loop/request-log
|
||||
*/
|
||||
|
||||
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
|
||||
import { headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
@@ -32,10 +33,8 @@ export function createTransmissionLog(): TransmissionLog {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append whatever header event makes the log reproduce this request's header.
|
||||
* The first request from an instance always records a full `initial` or `resume`
|
||||
* snapshot. Later requests record nothing when unchanged, a round-tripping
|
||||
* delta when expressible, or a full `fallback` snapshot otherwise.
|
||||
* Append the full header snapshot owed by this request: initial/resume for the
|
||||
* instance's first request, nothing when unchanged, or change otherwise.
|
||||
*
|
||||
* @param session - the session whose log explains the request.
|
||||
* @param state - this loop instance's bookkeeping (mutated on first log).
|
||||
@@ -52,12 +51,5 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const baseline = session.requestHeader()!
|
||||
if (headerEquals(baseline, header)) return
|
||||
const delta = diffHeader(baseline, header)
|
||||
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
|
||||
if (delta === undefined) return
|
||||
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
|
||||
session.append('request/header-delta', delta)
|
||||
} else {
|
||||
session.append('request/header', { header, reason: 'fallback' })
|
||||
}
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
|
||||
253
packages/core/agent-loop/src/tool-calls.ts
Normal file
253
packages/core/agent-loop/src/tool-calls.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
|
||||
* parallel calls use a bounded rolling pool and are reclassified before start.
|
||||
* Dispatch may overlap, while policy, results, and result context remain
|
||||
* model-ordered. Abort stops replenishment and drains started calls.
|
||||
*
|
||||
* Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls
|
||||
* skipped after abort receive synthetic error results so replay stays valid.
|
||||
* @module dsh-agent-loop/tool-calls
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
interface PlannedCall {
|
||||
block: ToolCallBlock
|
||||
exec: ToolExecutionInput
|
||||
}
|
||||
|
||||
/** Settled dispatch awaiting model-order finalization. */
|
||||
interface Slot {
|
||||
exec: ToolRunContext
|
||||
result: ToolExecutionResult
|
||||
needsPost: boolean
|
||||
}
|
||||
|
||||
/** One scheduler group outcome, including a drained cancellation. */
|
||||
interface GroupOutcome {
|
||||
consumed: number
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one assistant step's tool calls by their live concurrency mode.
|
||||
* Started calls receive ordered results. Abort drains them, records synthetic
|
||||
* results for unstarted calls, and returns with the signal still aborted after
|
||||
* accepting started-call context into the batch FIFO owned by the caller.
|
||||
* The committed step's AgentLoop driver boundary supplies the initiating Agent
|
||||
* that becomes each explicit {@link ToolExecutionInput.agent}.
|
||||
*
|
||||
* @param ctx - loop context that owns the tool registry and carries the initiating Agent.
|
||||
* @param turn - current turn number.
|
||||
* @param step - current step number.
|
||||
* @param toolCalls - assistant calls in model order.
|
||||
* @param signal - abort signal shared by the step.
|
||||
* @param maxParallel - validated in-flight cap.
|
||||
* @param acceptContext - accepts committed result context into the active batch.
|
||||
*/
|
||||
export async function executeToolCalls(
|
||||
ctx: Context,
|
||||
turn: number,
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
signal: AbortSignal,
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
|
||||
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
|
||||
const planned: PlannedCall[] = toolCalls.map(block => ({
|
||||
block,
|
||||
exec: {
|
||||
callId: block.id,
|
||||
name: block.name,
|
||||
arguments: parseArguments(block.arguments),
|
||||
agent,
|
||||
signal,
|
||||
},
|
||||
}))
|
||||
|
||||
let next = 0
|
||||
while (next < planned.length) {
|
||||
// Commit before classifying again so registry changes affect unstarted calls.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
const first = planned[next]!
|
||||
const mode = ctx.tools.executionMode(first.exec).kind
|
||||
const group = mode === 'parallel' ? planned.slice(next) : [first]
|
||||
const outcome = await runGroup(
|
||||
ctx, turn, step, group, mode, signal, maxParallel, acceptContext,
|
||||
)
|
||||
next += outcome.consumed
|
||||
if (outcome.aborted) {
|
||||
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
|
||||
function parseArguments(raw: string): unknown {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one exclusive barrier or parallel pool. Later calls are reclassified
|
||||
* before start; an exclusive reclassification waits for the current pool to
|
||||
* drain and remains for the caller's next barrier. Results and contexts commit
|
||||
* in model order. Abort stops starts, drains and commits started calls, accepts
|
||||
* their contexts into the owning batch, records results for skipped calls, and
|
||||
* returns an aborted outcome.
|
||||
*/
|
||||
async function runGroup(
|
||||
ctx: Context,
|
||||
turn: number,
|
||||
step: number,
|
||||
group: PlannedCall[],
|
||||
mode: ToolExecutionMode['kind'],
|
||||
signal: AbortSignal,
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<GroupOutcome> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||
// Started slots retain their tool/call seq for result provenance.
|
||||
const callSeqs: number[] = group.map(() => -1)
|
||||
let nextToStart = 0
|
||||
let committed = 0
|
||||
let started = 0
|
||||
let aborted: boolean = signal.aborted
|
||||
|
||||
// `committed` advances only across contiguous model-order slots.
|
||||
const commitReady = async (): Promise<void> => {
|
||||
while (committed < group.length) {
|
||||
const slot = slots[committed]
|
||||
if (slot === undefined) break
|
||||
const call = group[committed]
|
||||
const result = slot.needsPost
|
||||
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
|
||||
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
|
||||
for (const context of result.additionalContexts ?? []) acceptContext(context)
|
||||
committed++
|
||||
}
|
||||
}
|
||||
|
||||
const inFlight = new Map<number, Promise<number>>()
|
||||
|
||||
const startCall = async (index: number): Promise<void> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||
const call = group[index]!
|
||||
callSeqs[index] = appendToolCall(session, turn, step, call.block)
|
||||
started++
|
||||
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
|
||||
switch (prepared.kind) {
|
||||
case 'dispatch': {
|
||||
const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => {
|
||||
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
|
||||
return index
|
||||
})
|
||||
inFlight.set(index, promise)
|
||||
break
|
||||
}
|
||||
case 'post-result':
|
||||
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
|
||||
break
|
||||
case 'final-result':
|
||||
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(prepared, 'tool-call scheduler prepare result')
|
||||
}
|
||||
}
|
||||
|
||||
const fillPool = async (): Promise<void> => {
|
||||
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
|
||||
// Re-read later modes after ordered commits so registry changes can create a barrier.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
const nextCall = group[nextToStart]!
|
||||
if (nextToStart > 0 && mode === 'parallel'
|
||||
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
|
||||
await startCall(nextToStart)
|
||||
nextToStart++
|
||||
await commitReady()
|
||||
// Abort may arrive while pre-execute awaits.
|
||||
if (signal.aborted) aborted = true
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered pre-execute may await; only dispatch/body overlaps.
|
||||
// TODO: Drain every started call before rethrowing a scheduler error; tool
|
||||
// bodies must not outlive the failed turn.
|
||||
await fillPool()
|
||||
while (inFlight.size > 0) {
|
||||
const settledIndex = await Promise.race(inFlight.values())
|
||||
inFlight.delete(settledIndex)
|
||||
await commitReady()
|
||||
// Abort may arrive while a tool or ordered commit awaits.
|
||||
|
||||
if (signal.aborted) aborted = true
|
||||
await fillPool()
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
// Started calls and accepted context settle first; every remaining model
|
||||
// call then receives an ordered synthetic result before the turn aborts.
|
||||
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return { consumed: group.length, aborted: true }
|
||||
}
|
||||
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
|
||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||
return { consumed: started, aborted: false }
|
||||
}
|
||||
|
||||
/** Append the durable call/result pair for a model call skipped after cancellation. */
|
||||
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
|
||||
const callSeq = appendToolCall(session, turn, step, block)
|
||||
appendToolResult(session, turn, step, block, {
|
||||
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
}, callSeq)
|
||||
}
|
||||
|
||||
/** Append a started call and return its provenance sequence. */
|
||||
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
|
||||
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
|
||||
return event.seq
|
||||
}
|
||||
|
||||
/** Append a model-ordered result linked to its call event. */
|
||||
function appendToolResult(
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
block: ToolCallBlock,
|
||||
result: ToolExecutionResult,
|
||||
callSeq: number,
|
||||
): void {
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
// Correlation stays with the loop's authoritative model-transcript call id;
|
||||
// registry results deliberately do not duplicate it.
|
||||
callId: block.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
|
||||
}
|
||||
335
packages/core/agent-loop/tests/agent-initiator.spec.ts
Normal file
335
packages/core/agent-loop/tests/agent-initiator.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, 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'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
agentsFiber: Fiber
|
||||
loopFiber: Fiber
|
||||
}
|
||||
|
||||
async function harness(adapter: LlmAdapter): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const agentsFiber = await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, agentsFiber, loopFiber }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string): void {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Adapter that holds both drivers at the same awaited continuation. */
|
||||
class OverlapAdapter extends LlmAdapter {
|
||||
private readonly bothStarted = Promise.withResolvers<boolean>()
|
||||
private starts = 0
|
||||
readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
|
||||
|
||||
constructor(private readonly ctx: Context) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const before = this.ctx.agents.requireInitiator()
|
||||
this.starts += 1
|
||||
if (this.starts === 2) this.bothStarted.resolve(true)
|
||||
await this.bothStarted.promise
|
||||
await Promise.resolve()
|
||||
const after = this.ctx.agents.requireInitiator()
|
||||
this.observations.push({ sessionId: options.sessionId, before, after })
|
||||
yield* textResponse('done')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only transport that materializes ambient identity at its request boundary. */
|
||||
class TestCapabilityTransport {
|
||||
readonly requests: { path: string; headers: Record<string, string> }[] = []
|
||||
|
||||
constructor(private readonly agents: AgentRegistry) {}
|
||||
|
||||
async request(path: string): Promise<Record<string, string>> {
|
||||
await Promise.resolve()
|
||||
const headers = {
|
||||
'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
|
||||
}
|
||||
this.requests.push({ path, headers })
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapter whose first call waits for cancellation and whose later calls complete. */
|
||||
class ReloadAdapter extends LlmAdapter {
|
||||
readonly firstStarted = Promise.withResolvers<boolean>()
|
||||
firstAgentDuringAbort: Agent | undefined
|
||||
laterAgent: Agent | undefined
|
||||
calls = 0
|
||||
agents: AgentRegistry | undefined
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const agents = this.agents
|
||||
if (agents === undefined) throw new Error('agent service missing')
|
||||
this.calls += 1
|
||||
if (this.calls === 1) {
|
||||
this.firstStarted.resolve(true)
|
||||
try {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
const abort = (): void => { reject(new Error('aborted')) }
|
||||
if (options.signal?.aborted === true) abort()
|
||||
else options.signal?.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
await Promise.resolve()
|
||||
this.firstAgentDuringAbort = agents.requireInitiator()
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
await Promise.resolve()
|
||||
this.laterAgent = agents.requireInitiator()
|
||||
yield* textResponse('reloaded')
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentLoop initiator scope', () => {
|
||||
it('keeps overlapping driver continuations bound to their exact Agents', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverlapAdapter(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
|
||||
const idleA = waitForIdle(ctx, a)
|
||||
const idleB = waitForIdle(ctx, b)
|
||||
send(a, 'a')
|
||||
send(b, 'b')
|
||||
await Promise.all([idleA, idleB])
|
||||
|
||||
expect(adapter.observations).toHaveLength(2)
|
||||
expect(adapter.observations).toEqual(expect.arrayContaining([
|
||||
{ sessionId: a.session.id, before: a, after: a },
|
||||
{ sessionId: b.session.id, before: b, after: b },
|
||||
]))
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('spawn', 'spawn-child', {}),
|
||||
toolCallResponse('observe', 'observe-child', {}),
|
||||
textResponse('child done'),
|
||||
textResponse('parent done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
let parentDuringSetup: Agent | undefined
|
||||
let explicitChild: Agent | undefined
|
||||
let childDuringDriver: Agent | undefined
|
||||
let parentWhileChildDriverActive: Agent | undefined
|
||||
let child: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'spawn-child',
|
||||
description: 'create one child agent',
|
||||
parameters: {},
|
||||
execute: async (_args, exec) => {
|
||||
if (exec.agent === undefined) throw new Error('parent agent missing')
|
||||
const handle = await exec.agent.ctx.agents.create({
|
||||
sessionId: SessionId('child-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
parentDuringSetup = ctx.agents.requireInitiator()
|
||||
explicitChild = agentCtx.agent
|
||||
agentCtx.tools.register(defineTool({
|
||||
name: 'observe-child',
|
||||
description: 'observe child execution identity',
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
childDuringDriver = ctx.agents.requireInitiator()
|
||||
return [{ type: 'text', text: 'observed' }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
child = handle.agent
|
||||
parentWhileChildDriverActive = ctx.agents.requireInitiator()
|
||||
send(handle.agent, 'run child')
|
||||
await handle.agent.whenIdle()
|
||||
await handle.dispose()
|
||||
return [{ type: 'text', text: 'child completed' }]
|
||||
},
|
||||
}))
|
||||
|
||||
const parentHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('parent-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const idle = waitForIdle(ctx, parentHandle.agent)
|
||||
send(parentHandle.agent, 'spawn')
|
||||
await idle
|
||||
|
||||
expect(parentDuringSetup).toBe(parentHandle.agent)
|
||||
expect(explicitChild).toBe(child)
|
||||
expect(childDuringDriver).toBe(child)
|
||||
expect(parentWhileChildDriverActive).toBe(parentHandle.agent)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await parentHandle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
const transport = new TestCapabilityTransport(ctx.agents)
|
||||
let directAmbient: Agent | undefined
|
||||
let captured: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'agentless-probe',
|
||||
description: 'observe an agentless call',
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
directAmbient = ctx.agents.currentInitiator()
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'capability-request',
|
||||
description: 'call the test capability transport',
|
||||
parameters: { path: { type: 'string' } },
|
||||
execute: async (args) => {
|
||||
captured = ctx.agents.requireInitiator()
|
||||
const path = (args as { path: string }).path
|
||||
const headers = await transport.request(path)
|
||||
return [{ type: 'text', text: JSON.stringify(headers) }]
|
||||
},
|
||||
}))
|
||||
|
||||
const direct = await ctx.tools.execute({
|
||||
callId: CallId('direct'),
|
||||
name: 'agentless-probe',
|
||||
arguments: {},
|
||||
})
|
||||
expect(direct.isError).toBe(false)
|
||||
expect(directAmbient).toBeUndefined()
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('transport-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const idle = waitForIdle(ctx, handle.agent)
|
||||
send(handle.agent, 'call transport')
|
||||
await idle
|
||||
|
||||
expect(transport.requests).toEqual([{
|
||||
path: '/v1/capability',
|
||||
headers: { 'X-Harness-Session-Id': 'transport-session' },
|
||||
}])
|
||||
const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
|
||||
expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
|
||||
const call = handle.agent.session.events.find(event => event.type === 'tool/call')
|
||||
expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
|
||||
.toBe(JSON.stringify({ path: '/v1/capability' }))
|
||||
expect(captured).toBe(handle.agent)
|
||||
|
||||
await handle.dispose()
|
||||
expect(captured?.status).toBe('disposed')
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drains the old driver before disabling ALS during agent-service restart', async () => {
|
||||
const adapter = new ReloadAdapter()
|
||||
const { ctx, agentsFiber, loopFiber } = await harness(adapter)
|
||||
const oldService = ctx.agents
|
||||
adapter.agents = oldService
|
||||
const oldHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('before-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const oldAgent = oldHandle.agent
|
||||
send(oldAgent, 'block')
|
||||
await adapter.firstStarted.promise
|
||||
|
||||
await agentsFiber.restart()
|
||||
await loopFiber.await()
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
|
||||
expect(oldAgent.status).toBe('disposed')
|
||||
expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(ctx.agents).not.toBe(oldService)
|
||||
adapter.agents = ctx.agents
|
||||
|
||||
const newHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('after-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const newAgent = newHandle.agent
|
||||
const idle = waitForIdle(ctx, newAgent)
|
||||
send(newAgent, 'continue')
|
||||
await idle
|
||||
expect(adapter.laterAgent?.id).toBe(newAgent.id)
|
||||
expect(adapter.laterAgent?.session).toBe(newAgent.session)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new ReloadAdapter()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const service = ctx.agents
|
||||
adapter.agents = service
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('root-dispose-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
send(agent, 'block')
|
||||
await adapter.firstStarted.promise
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -22,7 +25,7 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -33,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
|
||||
function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === expected) {
|
||||
@@ -44,19 +47,23 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
describe('Agent', () => {
|
||||
it('rejects access before context binding and a second driver for one session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
expect(() => prepared.agent.ctx).toThrow('context is not bound')
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
|
||||
expect(() => prepareReactLoopAgent(
|
||||
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
))
|
||||
.toThrow('already has a concrete agent driver')
|
||||
|
||||
await prepared.dispose()
|
||||
@@ -65,13 +72,13 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
it('borrows caller options and binds its scoped context exactly once', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||
const options = { model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
|
||||
const options = { provider: 'mock', model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options)
|
||||
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
expect(agent.session.id).toBe(agent.id)
|
||||
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -79,14 +86,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -94,14 +101,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('steer() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -109,14 +116,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('inject() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -124,7 +131,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Simulate an OPEN turn in the log while the agent is idle (status is not a
|
||||
// reliable open-turn signal). inject must append into that open turn, NOT
|
||||
@@ -150,7 +157,7 @@ describe('ReactLoopAgent', () => {
|
||||
// A persistence-like listener whose flush rejects.
|
||||
ctx.on('session/flush', () => { throw new Error('disk gone') })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
|
||||
// flush must be contained (logged), never thrown into the caller.
|
||||
@@ -163,12 +170,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
// Invalid injected content throws after turn/start. `finally` must still append turn/end and
|
||||
// flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint.
|
||||
// Non-serializable injected content makes Session.append throw AFTER
|
||||
// turn/start was recorded. The turn/end must still be appended (finally),
|
||||
// AND the durability checkpoint must still fire — the balanced turn is in
|
||||
// memory and a crash before the next turn/dispose would otherwise lose it.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
@@ -181,7 +190,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
// Session contains a throwing post-commit turn/end observer. The accepted
|
||||
@@ -204,7 +213,7 @@ describe('ReactLoopAgent', () => {
|
||||
// A non-Error rejection exercises the String() normalization branch.
|
||||
ctx.on('session/flush', () => { throw 'disk gone' })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const errors: { turn: number; step: number; message: string }[] = []
|
||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
||||
|
||||
@@ -223,7 +232,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
||||
@@ -238,7 +247,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('steer() when idle falls through to send() and starts a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// steer while idle delegates to send
|
||||
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
@@ -250,20 +259,29 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('disposer is idempotent (double-stop)', async () => {
|
||||
// The internal start seam exposes one idle driver's disposer for repeated invocation.
|
||||
// Create a bare Agent and start it through the package-internal
|
||||
// test seam. Then call its disposer twice — the second call hits the
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
const firstDisposal = dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
await firstDisposal
|
||||
|
||||
// Second dispose — idempotent, no throw
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
@@ -272,7 +290,9 @@ describe('ReactLoopAgent', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
@@ -286,7 +306,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
@@ -305,7 +325,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() resolves immediately when the agent is not running', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Fresh agent is idle — whenIdle() takes the not-running fast path and
|
||||
// resolves without subscribing. await must not hang.
|
||||
@@ -316,7 +336,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'queued')
|
||||
let settled = false
|
||||
@@ -334,8 +354,8 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
|
||||
// agent/status and resolves on the first transition out of running.
|
||||
@@ -358,8 +378,10 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
|
||||
// Queue the internal waiter while running, then dispose the bare driver. Its disposed branch
|
||||
// must chain the loop's `done` promise rather than resolve before exit.
|
||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||
// while running (not the fast path), then the disposer settles it and chains
|
||||
// `done` (loop exit), not an eager resolve. A bare Agent + direct
|
||||
// internal driver disposer keeps the emit synchronous.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -369,7 +391,9 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
@@ -385,13 +409,15 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
|
||||
// The waiter is agent-owned state, not an effect-scoped listener that owner disposal would
|
||||
// remove before the disposed transition. Fiber teardown must still settle it.
|
||||
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
|
||||
// disposing the OWNING fiber runs the agent's listener disposers, which would
|
||||
// have dropped a ctx.on-based waiter before the 'disposed' transition and
|
||||
// hung the promise. With internal waiters, the fiber disposer still settles it.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -404,19 +430,21 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
|
||||
// Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it
|
||||
// resolves only after true loop exit.
|
||||
// The disposer emits agent/status('disposed') BEFORE the driver loop
|
||||
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
|
||||
// disposed path. Dispose a running agent, then assert whenIdle() resolves
|
||||
// only after `done` — i.e. the loop has actually exited.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
let doneResolved = false
|
||||
void agent.done.then(() => { doneResolved = true })
|
||||
void driverDone(agent).then(() => { doneResolved = true })
|
||||
await fiber.dispose() // sets status disposed, aborts, drains the loop
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
@@ -431,7 +459,7 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'running') throw new Error('bad running listener')
|
||||
})
|
||||
@@ -449,7 +477,7 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'idle') throw new Error('bad idle listener')
|
||||
})
|
||||
|
||||
@@ -12,10 +12,14 @@ import { Context } from 'cordis'
|
||||
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -29,12 +33,12 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next idle transition (event-based, not status poll). */
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -43,7 +47,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
}
|
||||
|
||||
/** All user-message texts recorded in the log (to assert what actually ran). */
|
||||
function userTexts(agent: ReactLoopAgent): string[] {
|
||||
function userTexts(agent: Agent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
|
||||
@@ -54,7 +58,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// The loop is parked at the idle wait with nothing queued. A cancel here must
|
||||
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
|
||||
@@ -71,11 +75,12 @@ describe('Agent.cancel()', () => {
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// 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.
|
||||
@@ -87,10 +92,39 @@ 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)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// This waiter cannot rely on a running→idle transition because cancellation
|
||||
// drops the turn before it runs; the skip path must settle it directly.
|
||||
@@ -106,10 +140,165 @@ 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(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -117,16 +306,20 @@ 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 () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -139,10 +332,63 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
|
||||
})
|
||||
|
||||
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'danger', {}),
|
||||
textResponse('recovered after cancellation'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
let executions = 0
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'danger',
|
||||
description: 'must not run after cancellation',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
executions += 1
|
||||
return [{ type: 'text', text: 'ran' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/message') {
|
||||
agent.cancel('cancelled after assistant message')
|
||||
}
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
expect(executions).toBe(0)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }])
|
||||
const call = agent.session.events.find(event => event.type === 'tool/call')
|
||||
const result = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
|
||||
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
|
||||
callId: 'c1',
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
|
||||
send(agent, 'continue safely')
|
||||
await waitForIdle(ctx, agent)
|
||||
const replayedResult = adapter.requests[1]!.messages
|
||||
.flatMap(message => message.content)
|
||||
.find(block => block.type === 'tool-result')
|
||||
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'aborted', reason: 'cancelled after assistant message' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
|
||||
const adapter = new MockAdapter(['hang', textResponse('second reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// First turn hangs; cancel it mid-step.
|
||||
send(agent, 'first')
|
||||
@@ -164,7 +410,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Prefix composition runs before the pre-step seam on the instance's first
|
||||
// step; a cancel landing inside it must drop the about-to-start step
|
||||
@@ -198,11 +444,10 @@ describe('Agent.cancel()', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -215,7 +460,7 @@ describe('Agent.cancel()', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
// No step opened, no model call ran, and the turn closed disposed.
|
||||
expect(streamed).toBe(false)
|
||||
@@ -227,7 +472,7 @@ describe('Agent.cancel()', () => {
|
||||
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// The interrupted first composition must not cache its degraded empty value;
|
||||
// the next prompt recomposes and logs/sends the fresh prefix.
|
||||
@@ -257,7 +502,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A turn/start listener fires before a step controller exists, so the
|
||||
// turn-scoped marker—not step abort—must drop the pending step.
|
||||
@@ -284,7 +529,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A step/start session-event listener fires AFTER step/start is appended
|
||||
// (and after the pre-step seam), so cancelling there lands in the SECOND
|
||||
@@ -323,11 +568,10 @@ describe('Agent.cancel()', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -338,7 +582,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
@@ -355,7 +599,7 @@ describe('Agent.cancel()', () => {
|
||||
// `aborted` and run NO second step.
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -387,7 +631,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// `agent/status` is synchronous, so cancellation can land after the first
|
||||
// pre-step check; the second check must drop the now-empty turn.
|
||||
@@ -411,7 +655,7 @@ describe('Agent.cancel()', () => {
|
||||
// Cancellation must not settle idle while replacement work remains queued.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let replaced = false
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -438,7 +682,7 @@ describe('Agent.cancel()', () => {
|
||||
// prompt B is queued before the loop resumes from the idle wait.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'A') // queues A (status still idle, loop microtask pending)
|
||||
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
|
||||
@@ -457,7 +701,7 @@ describe('Agent.cancel()', () => {
|
||||
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
@@ -7,15 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -23,7 +24,281 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function makeCoreContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('rejects an empty exact id before publishing an agent', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await expect(ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId(''), model: 'mock' }],
|
||||
})).rejects.toThrow('expected string length >= 1')
|
||||
expect(ctx.agents.get(SessionId(''))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
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('config-exact'), model: 'mock' }],
|
||||
})
|
||||
expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact')
|
||||
await exact.fiber.dispose()
|
||||
|
||||
const conflicting = await makeCoreContext()
|
||||
await expect(conflicting.plugin(AgentLoop, {
|
||||
agents: [{
|
||||
id: 'main',
|
||||
sessionId: SessionId('fresh'),
|
||||
resumeSessionId: SessionId('persisted'),
|
||||
model: 'mock',
|
||||
}],
|
||||
})).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive')
|
||||
await conflicting.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate exact ids before asynchronous configured startup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-duplicate-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
|
||||
const outcome = await ctx.plugin(AgentLoop, {
|
||||
agents: [
|
||||
{ id: 'first', sessionId: SessionId('shared'), model: 'mock' },
|
||||
{ id: 'second', sessionId: SessionId('shared'), model: 'mock' },
|
||||
],
|
||||
}).then(() => undefined, (error: unknown) => error)
|
||||
const published = ctx.agents.get(SessionId('shared'))
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(outcome).toEqual(new Error('agents "first" and "second" use duplicate exact session identity "shared"'))
|
||||
expect(published).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores a materialized exact id across an AgentLoop-only reload', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-'))
|
||||
dirs.push(root)
|
||||
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('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('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, first!)
|
||||
await firstLoop.dispose()
|
||||
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
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('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('config-exact-reload'))
|
||||
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
await secondLoop.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for a draining exact-id lifecycle during an overlapping reload', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
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()
|
||||
const first = ctx.agents.get(sessionId) as Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
let flushStarted = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== first.session) return
|
||||
flushStarted = true
|
||||
return flushGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
expect(flushStarted).toBe(true)
|
||||
|
||||
const firstDisposal = firstLoop.dispose()
|
||||
await expect.poll(() => first.status).toBe('disposed')
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.agents.get(sessionId)).toBe(first)
|
||||
expect(failures).toEqual([])
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
await firstDisposal
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
const second = ctx.agents.get(sessionId) as Agent
|
||||
expect(second).not.toBe(first)
|
||||
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
|
||||
expect(failures).toEqual([])
|
||||
|
||||
await secondLoop.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancels an exact-id reload while the prior lifecycle is still draining', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-cancel-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
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()
|
||||
const first = ctx.agents.get(sessionId) as Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session === first.session) return flushGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
|
||||
const firstDisposal = firstLoop.dispose()
|
||||
await expect.poll(() => first.status).toBe('disposed')
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
await secondLoop.dispose()
|
||||
expect(ctx.agents.get(sessionId)).toBe(first)
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
await firstDisposal
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('contains an exact-id persistence lookup failure', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const failure = new Error('persistence index failed')
|
||||
const listenerFailure = new Error('failure observer failed')
|
||||
const asyncListenerFailure = new Error('async failure observer failed')
|
||||
const failures: { sessionId: SessionId; error: unknown }[] = []
|
||||
ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure })
|
||||
ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never)
|
||||
ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
|
||||
failures.push({ sessionId, error })
|
||||
})
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'config-driven restore of "config-exact-failure" failed: persistence index failed',
|
||||
))
|
||||
expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: failure observer failed',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: async failure observer failed',
|
||||
)
|
||||
expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('contains startup and observer failures whose string coercion throws', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-unrenderable-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const unrenderable = {
|
||||
[Symbol.toPrimitive](): never {
|
||||
throw new Error('coercion escaped')
|
||||
},
|
||||
}
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', () => { throw unrenderable })
|
||||
// Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never)
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
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 "config-exact-unrenderable" failed: <unrenderable value>',
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: <unrenderable value>',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: <unrenderable value>',
|
||||
)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes',
|
||||
async (outcome) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
|
||||
const loop = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = loop.dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
if (outcome === 'resolve') listing.resolve([])
|
||||
else listing.reject(new Error('startup cancelled by teardown'))
|
||||
await disposal
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
},
|
||||
)
|
||||
|
||||
it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -32,7 +307,7 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
})
|
||||
|
||||
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
|
||||
@@ -53,11 +328,13 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
||||
const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
const a1 = ctx1.agents.list()[0] as Agent
|
||||
expect(a1.id).toBe(a1.session.id)
|
||||
expect(a1.session.id).toMatch(idPattern)
|
||||
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -70,10 +347,11 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
||||
const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
const a2 = ctx2.agents.list()[0] as Agent
|
||||
expect(a2.id).toBe(a2.session.id)
|
||||
expect(a2.session.id).toMatch(idPattern)
|
||||
expect(a2.session.id).not.toBe(a1.session.id)
|
||||
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
@@ -96,7 +374,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -109,19 +387,20 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
// The deferred resume runs on a microtask after the backend is available.
|
||||
let resumed: ReactLoopAgent | undefined
|
||||
let resumed: Agent | undefined
|
||||
for (let i = 0; i < 50 && !resumed; i++) {
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined
|
||||
resumed = ctx2.agents.get(SessionId('sticky-1'))
|
||||
}
|
||||
expect(resumed).toBeDefined()
|
||||
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
||||
// and the prior turn's user message is in the derived history.
|
||||
expect(resumed!.id).toBe(SessionId('sticky-1'))
|
||||
expect(resumed!.session.id).toBe('sticky-1')
|
||||
const derived = resumed!.session.deriveMessages()
|
||||
expect(JSON.stringify(derived)).toContain('remember me')
|
||||
@@ -137,16 +416,16 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
||||
|
||||
// The deferred resume fails (no such session on disk). It must be contained:
|
||||
// a warning is logged, no 'main' agent is registered, and the app stays up.
|
||||
// a warning is logged, no agent is registered, and the app stays up.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user