docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions
+24 -42
View File
@@ -5,53 +5,35 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
# Reviewing a DeepSeek-Harness PR
**This skill is guidance, not a complete checklist.** It is a where-to-look map that lowers your startup cost on an unfamiliar PR — clearing every item here does not mean the PR is good. You are the reviewer: reason independently from the code in front of you, and think broadly across every dimension a change can fail on. The items below are the failure modes this repo has already paid for; a real review also catches the ones nobody has written down yet.
Read the diff and enough surrounding code to understand the design, then verify suspected defects before reporting them. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits.
Independent judgment governs *what to look at* and *how to apply a rule to this case* — not whether the repo's documented requirements still hold. AGENTS.md, packages/AGENTS.md, and the [quality gates](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md) remain authoritative; a missing HMR-safety test or out-of-sync docs is a blocking gap regardless of your judgment, not a suggestion you can waive. Use your own reasoning to go *beyond* these checks and to weigh genuine edge cases against an RFC (raise it as a discussion, don't silently override) — never to demote a documented blocker to optional.
## Sources of truth
## How to think about a review
- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): repository and package rules.
- [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes.
- [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline.
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates RFC](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates.
- [RFC index](../../../docs/rfc/README.md): design rationale. Treat disagreement with an RFC as a design discussion, not an automatic veto.
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md).
- **Reason from the code, not from this list.** Read the diff and enough surrounding context to understand what the change actually does, then ask what could go wrong — independently of whether this skill names it. The named patterns are a floor, not a ceiling.
- **Think broadly, across many aspects.** A change can be wrong in correctness, concurrency/lifecycle, error handling, security, performance, API/contract design, type safety, test quality, docs sync, naming, readability, or backward compatibility. Also challenge the *approach itself*: is this the right design, are its assumptions sound, where does it fail under real-world conditions? Don't tunnel on the first defect you spot or stop at the checklists below — sweep all of them.
- **Verify before you flag.** Check a suspected issue against the actual codebase (grep the symbol, read the caller, confirm the path is reachable) before raising it. An unverified claim wastes the author's time and erodes trust in the review.
- **Calibrate confidence; suppress noise.** Distinguish a blocking bug from a nitpick and say which is which. Don't raise things a gate already enforces (typecheck, lint, formatting, type errors, broken tests), pre-existing issues on lines the PR didn't touch, or pedantic style a senior engineer would let slide. When unsure whether something is real, investigate or frame it explicitly as a question rather than a finding.
- **Severity, not volume.** Lead with what blocks merge. A short review that names the one real bug beats a long one that buries it under nits.
## Blocking requirements
## Sources of truth (read, don't re-summarize)
1. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home.
2. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry.
3. **Registrations clean up.** A new registry contribution has a test that disposes its owner and observes removal.
4. **Required gates pass.** Trust typecheck, lint, coverage, build, hygiene, doc-sync, and module-graph checks for what they enforce; review the semantic gaps they cannot detect.
These define the conventions and gates this repo is checked against, and they are authoritative. Read them at the source so this skill never drifts out of sync — and apply judgment in *interpreting* them for the case at hand, not in deciding whether they apply.
## Manual checks
- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, no hardcoded tunables in plugins, the empty-`catch` rule, symmetry.
- **[docs/defensive-patterns.md](../../../docs/defensive-patterns.md)** — each section is a bug class that bit us. Reviewing anything touching process lifecycle, async/await, disposal, or adapter error paths? Re-read this first — then look for the *adjacent* mistake it doesn't name.
- **AGENTS.md § Type safety and documentation + [docs/AGENTS.md](../../../docs/AGENTS.md)** — the doc-sync rule (code change ⇒ update README + JSDoc in the SAME commit) and the writing rules (current-state-never-history, one line per paragraph, one home per fact, the word-budget gate).
- **[packages/AGENTS.md](../../../packages/AGENTS.md)** — per-package conventions (file layout, the HMR-safety test requirement).
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md) and [docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the authoritative standard for bilingual-doc review: faithfulness, structure, typography, and the binding terminology table. For PRs touching translated docs or pending terms, read these before judging the translation; [dsh-translate-docs](../dsh-translate-docs/SKILL.md) is the translator workflow.
- **[RFC index](../../../docs/rfc/README.md)** — the *why* behind the architecture. Especially [quality gates](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md) (what a PR must pass) and [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) (the three-package split). If a change seems to fight an RFC, that's a discussion, not a silent override — and not an automatic veto either: an RFC can be wrong for this case, so reason about it.
- **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any RFC, including errors, cancellation, ownership, and disposal.
- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, and quiescent disposal.
- **Capability shape:** a swappable capability follows the interface / implementation / consumer split. Consumers depend on the interface, not a backend.
- **Configuration:** deployment-varying timeouts, caps, models, URLs, paths, and retry counts are validated `Config` fields, not literals or `DEFAULT_*` constants.
- **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export.
- **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct.
- **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review golden diffs as behavior changes, not formatting noise.
- **Bilingual changes:** compare meaning and terminology on both sides; a green pairing hash does not prove translation quality.
## Hard blockers (documented requirements — missing one blocks merge)
## Reporting findings
These come straight from the source docs above. They are not discretionary; absence is a blocking gap.
1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional.
2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call.
3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge.
4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (the full gate list is the `doc-sync` script in the root `package.json`), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that `doc-sync` only covers compilable `ts` blocks, generated-catalog freshness, markdown wrapping/links/refs, verbatim type-equiv blocks, word budgets, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it.
## Reviewer-only checks (gates can't catch these — judgment required)
Where your independent reasoning earns its keep. Start here, then keep going across the broader aspects above.
- **e2e verifies the world, not the agent's self-report.** For real-API tests, confirm the assertion re-runs the command/checks the file externally — a keyword probe lets a cheating agent pass. For a behavior change to the agent's real flows, a no-key/mock test alone is usually insufficient: a with-key e2e (especially a smoke test that boots the real example and checks the world) is cheap here and catches "green units, broken product" — encourage it rather than treating real-API tests as expensive (see [docs/testing.md](../../../docs/testing.md)).
- **Plugin export shape + real-loader coverage.** A new/changed `cordis.yml`-loaded plugin: is it a function/namespace plugin (`name`/`inject`/`Config`/`apply` named exports) with NO `export default`? A stray default export makes the Loader's `unwrapExports` drop `inject` and the plugin crashes at load with `cannot get property … without inject` — invisible to hand-built `ctx.plugin({...})` tests and to line coverage. Confirm there's a test driving it through the REAL loader path (the no-key subprocess e2e for ACP is the model). And any opportunistic read of a service NOT in `static inject` should use `ctx.get(name)`, not `ctx.<name>` (the property proxy throws through a foreign shadow). See [packages/AGENTS.md](../../../packages/AGENTS.md) and [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Seam discipline.** New swappable capability? Check it's split per the capability-seams RFC (interface / impl / consumer), and that the consumer injects the interface key, never an implementation type.
- **Hardcoded tunables that should be plugin config.** A literal timeout, grace period, output/truncation cap, result-count limit, retry count, buffer size, model name, API base URL, user agent, or filesystem path introduced inside a plugin belongs on the plugin's schemastery `Config` with the shipped value as its default (AGENTS.md § Conventions "No hardcoded tunables in plugins"). A named `DEFAULT_*` constant or a test-only injection seam is not configurability — the question to ask is whether a `cordis.yml` deployment can change the value without a code edit. Protocol/wire constants, semantic constants, values pinned by an external spec, and security invariants are exempt; a new `Config` field also needs its README row and range validation. No gate detects a hardcoded tunable — this check is entirely on the reviewer.
- **Test quality — sufficiency, not just coverage.** 100% per-file coverage and a green suite are necessary, not sufficient: they prove the lines *ran*, not that the feature *works the way it ships*. Judge whether the tests are sufficient on two axes. (1) **Would they fail if the behavior regressed?** A test that passes but asserts the wrong thing — or restates the implementation instead of the contract (events fired, disposal reached, the world changed) — is worse than none. (2) **Do they exercise the REAL thing, the way it's actually used?** Prefer the genuine collaborator over a fake, drive the change through its real entry path (the cordis Loader, the ACP bridge, a booted subprocess — not a hand-built `ctx.plugin({...})` that bypasses `unwrapExports`), and verify the WORLD (re-read the file/log/registry externally), not the agent's self-report. A test that fakes the inputs just enough to cover every line will agree with whatever the author assumed; the real thing won't. When a test sets up a *clean/happy* path to reach a line, ask whether the line's PURPOSE is exercised — e.g. a durability/teardown path "tested" by a fully-completed turn never proves the mid-flight teardown it exists for; a torn-tail recovery branch covered by a well-formed log never proves recovery. Flag tests that hit the line but not the scenario. See [docs/testing.md](../../../docs/testing.md) § "Test the real entry path" and § "Prefer the real implementation over a mock".
- **Snapshot coverage for transcript/UX changes.** If the PR changes the editor-facing transcript or end-to-end agent UX — the ACP bridge's event→update translation, the agent loop's observable output, tool presentation, or anything an editor renders — it must add or update a snapshot scenario (`examples/*/tests/**/*.snapshot.ts`, goldens under `examples/acp-agent/tests/snapshots/`) or note explicitly why none applies (AGENTS.md § Conventions). Review the golden diff itself: a changed `stdout.golden.txt` / `session.golden.txt` is a behavior change in disguise — confirm it's intended, not an accidental regression someone re-recorded away. A pure internal refactor with no observable-output change is exempt, but the PR should say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
- **Bilingual docs: review translation quality, not just pairing.** If the PR adds or edits a doc pair, read the changed English and Chinese sides and compare the meaning, not only the mechanical diff. Verify terms against [terminology.md](../../../docs/i18n/terminology.md), including first-occurrence annotations and "do not translate as" prohibitions; if a new term has no established precedent, the PR should keep it in English, list it under `待定术语`, and update the terminology table once the rendering is decided. A green `verify-translation-pairing` only proves hashes, switchers, and structure were recorded — it does not prove the translation is faithful, natural, or correctly termed. Treat [translation-rules.md](../../../docs/i18n/translation-rules.md) MUST/MUST NOT violations as blocking.
- **Intent and contracts.** Does the change do what the PR says, and honor the documented contract on *both* sides of every seam it touches (see [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) "Honor cross-seam contracts on BOTH sides")?
## How to respond
Technical, specific, non-performative — no "great catch", no "you're absolutely right". State the issue, where it is, and why it matters; cite the AGENTS.md bullet or ADR when one applies, but don't manufacture a citation for a finding that stands on its own reasoning. Separate blocking issues from suggestions so the author knows what gates merge. When replying to inline threads on GitHub, reply in the thread (`gh api repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies`), not as a top-level comment. If a suggestion would fight an ADR or an established convention, say so and link it rather than relitigating in the thread.
If you are the author *receiving* this review, evaluate each point on its technical merits before acting — verify against the codebase, push back with reasoning where the reviewer lacks context or is wrong, and fix what's correct without performative agreement. A review is a set of claims to evaluate, not orders to follow.
State the defect, location, impact, and evidence. Separate blockers from suggestions and omit issues already enforced by a green gate. Use the existing GitHub review thread for replies. When receiving review, verify each claim and fix or rebut it on technical grounds without performative agreement.
+9 -8
View File
@@ -5,7 +5,7 @@ description: 'Use when writing, moving, reviewing, or auditing documentation in
# Applying the DeepSeek Harness Documentation Standard
The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md) — the tier taxonomy, the word budgets, and the slop checklist. This skill is the workflow for applying it: placing content, auditing the corpus, and handling a red budget gate. It is guidance, not a script; keep judgment active and prefer a few well-proven fixes over a mass rewording pass.
The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers Markdown, JSDoc, and code comments; use judgment rather than treating length alone as a defect.
## Sources of truth (read, don't re-summarize)
@@ -28,13 +28,14 @@ Run the placement test in the standard's taxonomy table, then check the constrai
The audit is a hunt for the standard's slop checklist, cheapest probes first:
1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' | grep -v '^vendor/' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers.
2. Hunt narrated history: `rg -n -g '!vendor' -t md "no longer|used to|previously|was moved|renamed"` — judge each hit; some are legitimate (quoting a contrast against a live alternative), most are drift.
3. Hunt duplication: take each standing-doc rule, grep one distinctive phrase from it across all Markdown; more than one home means all but one become links.
4. Hunt catalog restatement: compare README event/tool tables against the generated catalogs and JSDoc; hand copies get replaced by links.
5. Hunt spec-speak in `implemented/` RFCs: migration plans, test checklists, future-tense "should" — an implemented RFC describes what is. The heading-level cases (`## Plan`, `## Acceptance criteria`, …) are mechanically gated by `verify-rfc-format`; the prose-level "should" hunt remains manual.
6. Classify each finding: a mechanical trim lands as a small PR; a restructure or removal that changes what a doc promises gets a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md) for the RFC shape).
2. Hunt narrated history: `rg -n -g '!vendor' "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts'` and keep only contrasts against a live alternative.
3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, and rejected local alternatives. Preserve only a non-obvious contract or durable rationale; otherwise delete the comment.
4. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links.
5. Replace hand-written catalog or JSDoc restatements with links to generated references.
6. In `implemented/` RFCs, remove migration plans, test checklists, and future-tense spec language; keep the decision, rationale, and shipped constraints.
7. If removing prose changes a promised behavior rather than its explanation, use a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md)).
Compression discipline: every load-bearing rule survives — as one to three lines plus a link to the home that carries its why. Cut stories, duplicates, and status annotations; never silently drop a rule. If a cut rule has no durable home to link, create it (usually an RFC or postmortem) in the same change.
Keep every load-bearing rule, preferably as one to three lines plus a link to its rationale. Cut stories, duplicates, status notes, and the path used to derive the rule. Do not create a new explanation merely to relocate disposable reasoning.
## When verify-doc-budgets goes red
@@ -42,4 +43,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do
## Validation and PR hygiene
For docs-only changes run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; if a paired doc was touched, update the counterpart (see [dsh-translate-docs](../dsh-translate-docs/SKILL.md)) and re-record with `pnpm run verify-translation-pairing --write`. Open a draft PR while the audit is still expanding; in the PR body, list what was trimmed/moved with word deltas, what was deliberately kept long and why, and which checks ran. The first audit cycle's deferred work list lives in [the doc-tiers-and-budgets RFC](../../../docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) § Deferred work.
Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write`. The PR body should give word deltas, explain any deliberately long exception, and list checks.
+19 -18
View File
@@ -1,10 +1,10 @@
# AGENTS.md
The DeepSeek Harness group monorepo, hosting **DeepSeek Harness SDK** a plugin-based SDK for building agent harnesses on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md).
DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; follow [docs/AGENTS.md](docs/AGENTS.md) for documentation.
## Pre-release stance: foundation over blast radius
**Applies only while the harness is unreleased — remove this section at the first tagged release.** With no external consumers, optimize for the correct foundation, not a small diff: move files, rename public symbols, repackage plugins, and update every reference in the same change. No backward-compat shims, deprecation aliases, or re-export stubs. On-disk formats need no migrations — a backend REJECTS anything not at the current version. Two sanctioned version stances: monotonic bump-and-reject (the SQLite backend's `SCHEMA_VERSION`), and a pinned `0` that absorbs all shape churn (`SESSION_FORMAT_VERSION` in `dsh-session`, documented "no compatibility implied"). Real version policy begins at the first release.
**Remove this section at the first tagged release.** With no external consumers, prefer the correct foundation over compatibility shims: rename or repackage freely and update every reference together. Backends reject old on-disk formats. SQLite uses monotonic `SCHEMA_VERSION`; `dsh-session` keeps `SESSION_FORMAT_VERSION` at `0` with no compatibility promise.
## Repository layout
@@ -57,7 +57,7 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
### Run the CI gates locally before marking a PR ready
During implementation, run the narrowest affected checks; run this full CI-equivalent sequence only when complete and before marking a PR ready. From a fresh clone/worktree, `pnpm run build` first because publint and NodeNext validate built `lib/`:
Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`:
```sh
set -euo pipefail
@@ -77,51 +77,52 @@ rm -rf .sessions
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
```
`test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run.
`test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run.
## Secrets / .env
Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from the environment or a gitignored root `.env` loaded via `process.loadEnvFile()`. cordis.yml references env vars with the `!!js` tag (never `!js`). Never commit credentials. CI has no secrets, so e2e suites self-skip without a key — a CI accommodation, not a cost signal; the with-key policy is in [docs/testing.md](docs/testing.md).
Real-API tests read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the environment or gitignored root `.env`. cordis.yml uses `!!js` (never `!js`) for env vars. Never commit credentials. CI e2e self-skips without a key; [docs/testing.md](docs/testing.md) owns the with-key policy.
## Conventions
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise; mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header.
- **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment.
- **Typed events use declaration merging**; extensible unions use merge-extensible maps. Event JSDoc needs `@mode` and payload `@param` tags; public service methods document parameters and non-void returns. Catalog gates enforce this.
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
- **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
- **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively.
- **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template).
- **No hardcoded tunables in plugins**: anything two deployments could want different — timeouts, caps, model names, base URLs — is a defaulted, validated `Config` field, not a literal; a `DEFAULT_*` constant or test-only seam is not configurability. The test: changeable from `cordis.yml`, no code edit. Protocol/wire constants, external-spec values, security invariants stay hardcoded.
- **Misconfiguration fails loud**: a config value referencing something that does not exist (a `toolOrder` tool name, a plugin path) throws — at load when the check is self-contained, else at the earliest moment the referent exists (for `toolOrder`, every prompt assembly) — never a silent skip.
- **No hardcoded tunables in plugins**: deployment choices are validated `Config` fields changeable from cordis.yml. Protocol constants, external specs, and security invariants stay fixed.
- **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
- **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.
- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR.
- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/`.
- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript/UX changes need snapshots or a PR note. Snapshot fixtures must replay on macOS/Linux; avoid GNU/BSD-only commands (e.g. `sed -i`); fix fixtures, not normalizers.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Validate RFC premises against current code** and amend proposals before moving them to `implemented/`.
- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
- **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise.
- **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces.
- **Merge PRs with merge commits**, never squash/rebase or rewrite pushed branches. Put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it.
## Defensive patterns
[docs/defensive-patterns.md](docs/defensive-patterns.md) carries the hard-won bug-class rules: report orthogonal outcomes independently; honor cross-seam contracts on both sides; async state is not synchronous state; dispose must reach quiescence; contain callback exceptions; never hand untrusted output the ambient environment or predictable paths. Read it before lifecycle, concurrency, subprocess, or teardown work.
Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, concurrency, subprocess, or teardown work.
## Type safety and documentation
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones. Heritage-declared members, plugin-protocol slots, and constructors are exempt — their docs' one home is the seam declaration, the framework protocol, and the class doc respectively. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example).
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class.
Comments and docs record contracts, not the author's reasoning process. Do not narrate control flow, walk through tests, list rejected local alternatives, preserve review history, or restate code; delete an obvious comment and link to the one durable rationale home when more context is needed. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally.
Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
## Editing these instructions
`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. Keep it self-contained: state each principle inline instead of citing RFCs (they stay discoverable via the RFC index); linking high-level docs — architecture, testing, cookbooks — is fine. This file is budget-gated (`verify-doc-budgets`): condense first if it is possible without sacrificing clarity; truly needed additions may justify a ceiling raise.
`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep rules self-contained, link high-level docs, and condense before changing the `verify-doc-budgets` ceiling.
## Vendoring policy
+6 -4
View File
@@ -1,6 +1,6 @@
# AGENTS.md — The documentation standard
This file is the contract for every Markdown files in the repo: each tier's job, the writing rules, and the word budgets that `verify-doc-budgets` enforces. The audit/apply workflow is the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill; the decision record is [the doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md).
This file defines each Markdown tier, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for audits; rationale lives in the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md).
## The tier taxonomy: one home per fact
@@ -24,18 +24,19 @@ Placement test: a story about a bug → postmortem. Why we chose X → RFC. How
## Writing rules
- **Document the current state — never the process or history that produced it.** Prose describes what the code IS and why, as if it had always been so: no "previously/now/no longer/used to/renamed/moved here", and never name a change unit the reader cannot see — a PR, commit, or stack position in comments, JSDoc, or test names; name the mechanism instead. A genuinely clarifying contrast is framed against the live alternative as a standing fact, not against the past. The change story belongs in the commit message, the PR description, or an RFC.
- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems.
- **A decision worth re-litigating gets an RFC in the same PR.** The test: would a maintainer six months out ask "why was it done this way?" and find no answer in the code? If yes, write one ([when to write one](rfc/README.md)); mechanical or self-evident changes need none.
- **One physical line per paragraph** (`verify-md-wrap`): the editor soft-wraps; hard breaks make a one-word edit re-diff the whole paragraph. Prose only — code blocks, tables, and list structure stay; code comments stay under the linter's column limit.
- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
- **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)).
- **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)).
- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)).
- **Comments and JSDoc are contracts, not reasoning transcripts.** Keep only non-obvious behavior, constraints, and rationale at the closest public seam. Do not narrate the implementation, explain each test step, preserve review analysis, or restate what the code already says; delete instead of paraphrasing an obvious comment.
- Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams".
## Wordcount Budgets
Every PR has a lesson it wants to append, and without pressure nothing leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) stores the allowed word-count ceiling for each budgeted standing doc; `pnpm run verify-doc-budgets` fails when a doc exceeds its ceiling or a budgeted file is missing.
[scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) sets standing-doc ceilings; `pnpm run verify-doc-budgets` rejects excess or missing files.
When the gate goes red:
@@ -54,12 +55,13 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar
- A war story told inline where a one-line rule plus a postmortem/RFC link would do.
- Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it.
- Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead.
- Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it.
- Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home.
- Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior.
- Spec-speak in `implemented/` RFCs: "should", migration plans, acceptance checklists. An implemented RFC describes what is, per [rfc/implemented/AGENTS.md](rfc/implemented/AGENTS.md).
## Cross-reference with machine-checkable links, never free prose
When one doc refers to another doc, an RFC, a package README, or any file in the repo, link it with a relative Markdown link to the actual path never bare prose or a number ("see RFC 005"), which is uncheckable and rots on rename. `pnpm run verify-md-links` (part of `doc-sync`; see [the cross-link lint RFC](rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md)) fails when a relative target does not exist, so a rename that orphans a link is caught before review. This is also why RFC files carry dates and topics instead of stable numbers: they survive moves between lifecycle and class folders without dangling references.
Link repository references with relative Markdown paths, never bare filenames or RFC numbers. `verify-md-links` catches missing targets; the [cross-link RFC](rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md) owns the rationale.
The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one.
+48 -112
View File
@@ -31,7 +31,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:214`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-agent`
@@ -63,22 +63,17 @@ export interface Config {
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/ui/acp-agent/src/index.ts:52`](../packages/ui/acp-agent/src/index.ts)
Source: [`packages/ui/acp-agent/src/index.ts:27`](../packages/ui/acp-agent/src/index.ts)
## `@deepseek-ai/dsh-agent-core`
```ts config-catalog
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* and `skills` to the skill registry/local provider/tool consumer. Every field
* is optional INPUT here because each owner's schema supplies the default;
* the schema is the INTERSECTION of the owners' own schemas (with registry
* schemas nested under their bundle keys), so validation and defaulting can
* never drift from them.
* Bundle config: each field forwarded verbatim to the child that owns it — `agents` to the
* agent loop (an app that pre-creates no agents, like the ACP bridge, simply omits it),
* `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and
* the explicit model-facing tool order), the `tools` object to the tool registry (its
* presentation `mode`), and `skills` to the skill registry/local provider/tool consumer.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -106,7 +101,7 @@ export interface SkillConfig {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts)
Source: [`packages/core/agent-core/src/index.ts:87`](../packages/core/agent-core/src/index.ts)
Source: [`packages/core/agent-core/src/index.ts:40`](../packages/core/agent-core/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -125,17 +120,8 @@ export interface Config {
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
* demo can continue a prior conversation without code changes. Requires a
* `dsh-session-persistence` backend; the resume is deferred until that
* service is available (via `ctx.inject`) and the loaded session's events
* seed the live session so history continues.
*
* The schema accepts a plain string at runtime (cordis.yml values are
* untyped); the brand is compile-time only — the config format is the
* boundary where an id enters, so the TYPE declares the brand here.
* If set, the config agent RESUMES this persisted session id instead of starting a fresh
* `${id}-session-<uuid>`.
*/
resumeSessionId?: SessionId
})[]
@@ -164,7 +150,7 @@ export interface Config {
}
```
Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts)
Source: [`packages/bash/bash-local/src/index.ts:19`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-bash-sandbox`
@@ -191,7 +177,7 @@ export interface Config extends LocalConfig {
Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md)
Source: [`packages/bash/bash-sandbox/src/index.ts:60`](../packages/bash/bash-sandbox/src/index.ts)
Source: [`packages/bash/bash-sandbox/src/index.ts:23`](../packages/bash/bash-sandbox/src/index.ts)
## `@deepseek-ai/dsh-code-runtime-worker`
@@ -227,7 +213,7 @@ export interface Config {
}
```
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:29`](../packages/code-runtime/code-runtime-worker/src/index.ts)
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:22`](../packages/code-runtime/code-runtime-worker/src/index.ts)
## `@deepseek-ai/dsh-compact-basic`
@@ -280,7 +266,7 @@ export interface Config {
}
```
Source: [`packages/fs/fs-local/src/index.ts:58`](../packages/fs/fs-local/src/index.ts)
Source: [`packages/fs/fs-local/src/index.ts:50`](../packages/fs/fs-local/src/index.ts)
## `@deepseek-ai/dsh-hooks-claude`
@@ -316,7 +302,7 @@ export interface Config {
}
```
Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts)
Source: [`packages/hooks/hooks-claude/src/index.ts:39`](../packages/hooks/hooks-claude/src/index.ts)
## `@deepseek-ai/dsh-hooks-codex`
@@ -341,7 +327,7 @@ export interface Config {
}
```
Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts)
Source: [`packages/hooks/hooks-codex/src/index.ts:32`](../packages/hooks/hooks-codex/src/index.ts)
## `@deepseek-ai/dsh-invariants`
@@ -359,7 +345,7 @@ export interface Config {
}
```
Source: [`packages/support/invariants/src/index.ts:48`](../packages/support/invariants/src/index.ts)
Source: [`packages/support/invariants/src/index.ts:34`](../packages/support/invariants/src/index.ts)
## `@deepseek-ai/dsh-llm-deepseek`
@@ -386,7 +372,7 @@ export interface Config {
}
```
Source: [`packages/llm/llm-deepseek/src/index.ts:43`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:29`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
@@ -439,7 +425,7 @@ export interface Config {
}
```
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
Source: [`packages/support/llm-replay/src/index.ts:312`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
@@ -471,7 +457,7 @@ export interface Config {
}
```
Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts)
Source: [`packages/guard/repeat-tool-guard/src/index.ts:24`](../packages/guard/repeat-tool-guard/src/index.ts)
## `@deepseek-ai/dsh-sandbox-local`
@@ -479,20 +465,7 @@ Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/r
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
/**
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
* appended). A NON-EMPTY argv is the operator's assertion that this runner
* exists and FULLY enforces the profile (confinement reports
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
* — carries both Linux file-denial dialects as its denial signatures) —
* the runner chain and its probes are skipped,
* and a broken runner fails loudly at execution time. The operator also
* supplies {@link runnerFailureSignatures}, which distinguish the runner
* refusing its profile from the wrapped command failing normally.
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
* built-in platform chains — Linux `bwrap` then the Landlock launcher
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
* selected without a probe). Used for custom/alternative runners and
* for deterministic fake runners in keyless test tiers.
* Override the sandbox runner argv (the bwrap-shaped profile arguments are appended).
*/
runnerCommand?: string[]
/**
@@ -518,7 +491,7 @@ export interface Config {
}
```
Source: [`packages/sandbox/sandbox-local/src/index.ts:36`](../packages/sandbox/sandbox-local/src/index.ts)
Source: [`packages/sandbox/sandbox-local/src/index.ts:17`](../packages/sandbox/sandbox-local/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-jsonl`
@@ -536,7 +509,7 @@ export interface Config {
}
```
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:35`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:21`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-sqlite`
@@ -571,7 +544,7 @@ export interface Config {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:36`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
## `@deepseek-ai/dsh-skill`
@@ -642,7 +615,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts)
Source: [`packages/ui/stdio-agent/src/index.ts:33`](../packages/ui/stdio-agent/src/index.ts)
## `@deepseek-ai/dsh-subagent-acp`
@@ -698,7 +671,7 @@ export interface Config {
export type PermissionPolicy = 'allow' | 'reject'
```
Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/subagent-acp/src/index.ts)
Source: [`packages/subagent/subagent-acp/src/index.ts:17`](../packages/subagent/subagent-acp/src/index.ts)
## `@deepseek-ai/dsh-subagent-fork`
@@ -712,7 +685,7 @@ export interface Config {
}
```
Source: [`packages/subagent/subagent-fork/src/index.ts:38`](../packages/subagent/subagent-fork/src/index.ts)
Source: [`packages/subagent/subagent-fork/src/index.ts:24`](../packages/subagent/subagent-fork/src/index.ts)
## `@deepseek-ai/dsh-subagent-mock`
@@ -745,7 +718,7 @@ export interface Config {
Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts)
Source: [`packages/support/subagent-mock/src/index.ts:80`](../packages/support/subagent-mock/src/index.ts)
## `@deepseek-ai/dsh-subagent-spawn`
@@ -759,7 +732,7 @@ export interface Config {
}
```
Source: [`packages/subagent/subagent-spawn/src/index.ts:35`](../packages/subagent/subagent-spawn/src/index.ts)
Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagent/subagent-spawn/src/index.ts)
## `@deepseek-ai/dsh-system-prompt`
@@ -767,48 +740,21 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:35`](../packages/subagen
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
* The deployment's persona — the ONE deployment-authored fragment of the
* system prompt, rendered as the order-0 `deployment:persona` section
* (after the harness identity, before all tool guidance). Every agent in
* the context shares it by default; a per-agent persona is a SCOPED section
* of the same name registered through that agent's `agent.ctx` (it shadows
* this one for that agent — the subagent seam's `persona` request field does
* exactly that). Template, not free-form text:
* every complete `{{…}}` group is interpreted strictly against the
* registered prompt variables (the shipped agent loop registers `{{model}}`
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
* `''` — the empty section is dropped at render, so a persona-less
* deployment opens with the harness identity alone.
* The deployment's persona — the one deployment-authored fragment of the system prompt,
* rendered as the order-0 `deployment:persona` section (after the harness identity, before
* all tool guidance).
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, and tools absent from the list are
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
* lexicographic name order. A configured list must contain the rest entry
* exactly once, no duplicate names, and no name without a registered tool —
* a misconfigured order blocks work instead of silently reaching a model
* request: shape violations throw at load, and an unregistered name rejects
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
* not be a collected tool name; such a provider output also rejects the
* assembly. The single assembly-time validation rejects either failure
* before any model request — the earliest moment the registered tool set
* exists to check against, since tool plugins register after this service
* constructs. When omitted, tools are ordered lexicographically by name.
* Applied to the tools
* {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed tools take their
* listed position, and tools absent from the list are inserted at the {@link
* TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in lexicographic name order.
*/
toolOrder?: string[]
}
```
Source: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:211`](../packages/core/system-prompt/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
@@ -826,7 +772,7 @@ export interface Config {
}
```
Source: [`packages/cordis/tool-cordis/src/index.ts:53`](../packages/cordis/tool-cordis/src/index.ts)
Source: [`packages/cordis/tool-cordis/src/index.ts:22`](../packages/cordis/tool-cordis/src/index.ts)
## `@deepseek-ai/dsh-tool-fs`
@@ -846,7 +792,7 @@ export interface Config {
}
```
Source: [`packages/fs/tool-fs/src/index.ts:48`](../packages/fs/tool-fs/src/index.ts)
Source: [`packages/fs/tool-fs/src/index.ts:30`](../packages/fs/tool-fs/src/index.ts)
## `@deepseek-ai/dsh-tool-skill`
@@ -920,7 +866,7 @@ export interface Config {
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
Source: [`packages/subagent/tool-subagent/src/index.ts:44`](../packages/subagent/tool-subagent/src/index.ts)
Source: [`packages/subagent/tool-subagent/src/index.ts:20`](../packages/subagent/tool-subagent/src/index.ts)
## `@deepseek-ai/dsh-tool-web`
@@ -942,7 +888,7 @@ export interface Config {
}
```
Source: [`packages/web/tool-web/src/index.ts:40`](../packages/web/tool-web/src/index.ts)
Source: [`packages/web/tool-web/src/index.ts:28`](../packages/web/tool-web/src/index.ts)
## `@deepseek-ai/dsh-tool-workflow`
@@ -958,7 +904,7 @@ export interface Config {
}
```
Source: [`packages/workflow/tool-workflow/src/index.ts:39`](../packages/workflow/tool-workflow/src/index.ts)
Source: [`packages/workflow/tool-workflow/src/index.ts:23`](../packages/workflow/tool-workflow/src/index.ts)
## `@deepseek-ai/dsh-tools`
@@ -968,18 +914,8 @@ Requires: `systemPrompt`
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* visible end capability as a native wire function definition. Under
* `'code'` this registry contributes exactly ONE wire tool,
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
* The presentation mode. `'native'` (the default) contributes every visible end capability
* as a native wire function definition.
*/
mode?: ToolPresentationMode
}
@@ -988,7 +924,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:409`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:334`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
@@ -1019,7 +955,7 @@ export interface Config {
export type ApprovalPolicy = 'ask' | 'never'
```
Source: [`packages/ui/user-approval/src/index.ts:268`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:229`](../packages/ui/user-approval/src/index.ts)
## `@deepseek-ai/dsh-web`
@@ -1038,7 +974,7 @@ export interface WebServiceConfig {
}
```
Source: [`packages/web/web/src/index.ts:68`](../packages/web/web/src/index.ts)
Source: [`packages/web/web/src/index.ts:60`](../packages/web/web/src/index.ts)
## `@deepseek-ai/dsh-web-fetch-local`
@@ -1088,7 +1024,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-deepseek/src/index.ts:48`](../packages/web/web-search-deepseek/src/index.ts)
Source: [`packages/web/web-search-deepseek/src/index.ts:39`](../packages/web/web-search-deepseek/src/index.ts)
## `@deepseek-ai/dsh-web-search-exa`
@@ -1160,7 +1096,7 @@ export interface Config {
}
```
Source: [`packages/workflow/workflow-workerthread/src/index.ts:69`](../packages/workflow/workflow-workerthread/src/index.ts)
Source: [`packages/workflow/workflow-workerthread/src/index.ts:33`](../packages/workflow/workflow-workerthread/src/index.ts)
## Loadable plugins with no config
+95 -67
View File
@@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
### `agent/created` — emit
An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup.
An agent's fully composed scoped world was published in the AgentRegistry.
```ts cordis-catalog
'agent/created'(this: Scoped<Agent>, agent: Agent): void
@@ -23,11 +23,13 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:300`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
An agent was removed from the registry after its driver and any in-flight turn reached quiescence. Ordered teardown may still be detaching the session and unwinding the agent's scoped registrations when this notification runs.
An agent was removed from the registry after its driver and any in-flight turn reached quiescence.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
@@ -35,11 +37,13 @@ An agent was removed from the registry after its driver and any in-flight turn r
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event.
A step or turn errored.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
@@ -47,13 +51,11 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:587`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet.
Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
Awaited checkpoint for surface mutation before `step/start` snapshots request history. Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
@@ -61,11 +63,13 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit.
Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
@@ -73,23 +77,27 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:437`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
A message entered the agent's inbox (queued or steering). `source` is the resolved source (defaults applied), not the caller's raw options.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
```
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit.
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
@@ -97,15 +105,13 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests.
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider's system slot) on every request this loop instance sends.
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
@@ -113,11 +119,13 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:518`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup).
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …).
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
@@ -125,11 +133,13 @@ The agent's session lifecycle began, fired once before its first turn. `source`
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns.
Agent status changed (`idle` ⇄ `running`, or → `disposed`).
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
@@ -137,23 +147,27 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
```
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:533`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.
Waterfall: override the turn-continuation decision via a typed ContinuationDecision.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
@@ -161,11 +175,13 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:551`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts)
### `agent/turn-stop` — serial
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn. A malformed non-undefined result fails the turn closed.
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.
Scope-filtered dispatch: keyed to `agent`.
```ts cordis-catalog
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
@@ -173,13 +189,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:306`](../../packages/core/agent/src/types.ts)
## `approval/*`
### `approval/request` — waterfall
Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is the service's shallow-frozen acceptance snapshot: later caller mutation cannot redirect the question, while the `agent` and `signal` identity capabilities remain exact.
Waterfall asking the composed answerers to decide one approval request.
```ts cordis-catalog
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
@@ -187,13 +203,13 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
Source: [`packages/ui/user-approval/src/index.ts:72`](../../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:33`](../../packages/ui/user-approval/src/index.ts)
## `fs/*`
### `fs/edit-intent` — waterfall
Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-fs-policy` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-intent').
Single-slot decision: produce the optional version guard for the next FileSystem.editText.
```ts cordis-catalog
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
@@ -201,11 +217,11 @@ Single-slot decision: produce the optional version guard for the next FileSystem
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:60`](../../packages/fs/fs/src/index.ts)
### `fs/observed` — emit
Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`): the tool does not guard the emit, so a listener that throws surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context.
Record that an actor observed a target at a version, after a successful read/write/edit.
```ts cordis-catalog
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
@@ -213,11 +229,11 @@ Record that an actor observed a target at a version, after a successful read/wri
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:69`](../../packages/fs/fs/src/index.ts)
### `fs/write-intent` — waterfall
Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here.
Single-slot decision: produce the write intent for the next FileSystem.writeText.
```ts cordis-catalog
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
@@ -225,7 +241,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:51`](../../packages/fs/fs/src/index.ts)
## `llm/*`
@@ -245,17 +261,19 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts
### `session/created` — emit
A session was created in the store. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
A session was created in the store. Dispatch uses the session's captured owner scope.
```ts cordis-catalog
'session/created'(this: Scoped<Session>, session: Session): void
```
Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:43`](../../packages/core/session/src/index.ts)
### `session/event` — emit
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
An event was appended to a session log (sync, fire-and-forget).
Scope-filtered dispatch: keyed to the session's captured owner.
```ts cordis-catalog
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
@@ -263,17 +281,19 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
Types: [SessionEvent](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts)
### `session/flush` — parallel
Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the caller waits for all of them, but none can veto. Dispatch it through SessionStore.flush — the store owns the carrier — never via a raw `ctx.parallel`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
Awaited durability checkpoint.
Scope-filtered dispatch: keyed to the session's captured owner.
```ts cordis-catalog
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
```
Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:60`](../../packages/core/session/src/index.ts)
## `skill/*`
@@ -301,13 +321,13 @@ Source: [`packages/skill/skill/src/index.ts:136`](../../packages/skill/skill/src
### `subagent/end` — emit
A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`). Paired with Events['subagent/start']; a run whose readiness rejected emits neither event. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`). Paired with Events['subagent/start']; a run whose readiness rejected emits neither event. Dispatch is scoped to the delegating parent. Scope-filtered dispatch: keyed to the delegating parent.
```ts cordis-catalog
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
```
Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:80`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit
@@ -317,7 +337,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der
'subagent/provider-added'(provider: SubagentProvider): void
```
Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:49`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-removed` — emit
@@ -327,29 +347,31 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:60`](../../packages/subagent/subagent/src/index.ts)
### `subagent/start` — emit
A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child. For an in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to resolve during this notification. A readiness rejection emits neither lifecycle event; every emitted start is paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.
Scope-filtered dispatch: keyed to the delegating parent.
```ts cordis-catalog
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
```
Source: [`packages/subagent/subagent/src/index.ts:101`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*`
### `system-prompt/assemble` — waterfall
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate.
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.
```ts cordis-catalog
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
```
Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts)
### `system-prompt/change` — emit
@@ -359,7 +381,7 @@ A section, tool provider, variable provider, or protection was registered or unr
'system-prompt/change'(): void
```
Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:40`](../../packages/core/system-prompt/src/index.ts)
## `tools/*`
@@ -371,11 +393,13 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:176`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which capability or scope was authorized. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less).
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
```ts cordis-catalog
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
@@ -383,11 +407,13 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. Core tool dispatch runs earlier as the base `next()` of the `tools/execute` waterfall, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` fires only for that agent's calls; a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less).
Waterfall after a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`).
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
```ts cordis-catalog
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
@@ -395,11 +421,11 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. The returned union is validated as an exact runtime shape before approval or guards run; a malformed JavaScript/casted decision fails closed as an `isError` result and the tool body never runs. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less).
Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`).
```ts cordis-catalog
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
@@ -407,11 +433,13 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
Types: [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:84`](../../packages/core/tools/src/index.ts)
### `tools/result` — parallel
Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
```ts cordis-catalog
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
@@ -419,7 +447,7 @@ Awaited notification of the authoritative FINAL tool outcome, after the complete
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:166`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:115`](../../packages/core/tools/src/index.ts)
## `workflow/*`
@@ -431,7 +459,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:98`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:82`](../../packages/workflow/workflow/src/index.ts)
### `workflow/agent-start` — emit
@@ -441,7 +469,7 @@ One `agent()` call established a ready child run. Paired with Events['workflow/a
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:87`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:71`](../../packages/workflow/workflow/src/index.ts)
### `workflow/end` — emit
@@ -451,7 +479,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:108`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:92`](../../packages/workflow/workflow/src/index.ts)
### `workflow/log` — emit
@@ -461,7 +489,7 @@ The script emitted a narration line (a `log(message)` call).
'workflow/log'(info: WorkflowRunInfo, message: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:77`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:61`](../../packages/workflow/workflow/src/index.ts)
### `workflow/phase` — emit
@@ -471,7 +499,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs
'workflow/phase'(info: WorkflowRunInfo, title: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:54`](../../packages/workflow/workflow/src/index.ts)
### `workflow/start` — emit
@@ -481,7 +509,7 @@ A workflow run started — the script's meta block validated, the body about to
'workflow/start'(info: WorkflowRunInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:62`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:46`](../../packages/workflow/workflow/src/index.ts)
## Inherited events (cordis core + loader/hmr/timer)
+17 -71
View File
@@ -21,7 +21,7 @@ async createAgent(options: CreateAgentOptions): Promise<AgentHandle>
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:71`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:62`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
@@ -40,33 +40,24 @@ list(): Agent[]
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:169`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:141`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`
The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here.
Owns the policy tier too (`effective = fold(the session's 'approval/policy' events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'` before dispatching any interactive answerer, a per-agent prompt section states a `'never'` policy (and only that one in prose — an `'ask'` promise could overclaim an answerer that headless compositions do not have), and an `agent/pre-step` narrator injects at most one coalesced notice when a session's effective policy moved past what the model was last told.
```ts cordis-catalog
async request(req: ApprovalRequest): Promise<ApprovalOutcome>
```
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
Source: [`packages/ui/user-approval/src/index.ts:292`](../../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:244`](../../packages/ui/user-approval/src/index.ts)
## `ctx.bash` — `BashExecutor` (abstract seam)
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Semantics every implementation must honor:
- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception.
- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed.
- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available.
- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`).
```ts cordis-catalog
abstract resolve(request: BashExecRequest): BashExecSpec
abstract run(spec: BashExecSpec): Promise<BashRunResult>
@@ -81,38 +72,24 @@ onTaskDone(listener: BashTaskListener): () => void
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md)
Source: [`packages/bash/bash/src/index.ts:62`](../../packages/bash/bash/src/index.ts)
Source: [`packages/bash/bash/src/index.ts:36`](../../packages/bash/bash/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
Abstract code-execution service. Subclass, implement run and the two descriptors, and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every implementation must honor:
- run resolves with an error FIELD for every program outcome — parse/transform failures, thrown exceptions, budget expiry, abort, substrate death (CodeRunFailure's taxonomy). It REJECTS only for caller misuse of the seam itself (e.g. a run submitted after disposal).
- Binding calls bridge to the caller's CodeBindingFunctions verbatim; arguments and resolutions must be structured-cloneable, and the runtime treats the program as a hostile peer (arbitrary binding names are own properties, malformed traffic is rejected or ignored, never crashes the host).
- Runs are isolated from each other: no state survives from one run to the next through the runtime.
- Disposal reaches quiescence: in-flight runs are terminated AND awaited before the service's own teardown completes (no orphan substrate survives `fiber.dispose()`).
```ts cordis-catalog
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
```
Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md)
Source: [`packages/code-runtime/code-runtime/src/index.ts:59`](../../packages/code-runtime/code-runtime/src/index.ts)
Source: [`packages/code-runtime/code-runtime/src/index.ts:29`](../../packages/code-runtime/code-runtime/src/index.ts)
## `ctx.compact` — `CompactService` (abstract seam)
Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation.
Implementations MUST honor:
- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance).
- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery.
```ts cordis-catalog
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
@@ -120,21 +97,12 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com
Types: [Message](../core-data-structures/core.md)
Source: [`packages/compact/compact/src/index.ts:65`](../../packages/compact/compact/src/index.ts)
Source: [`packages/compact/compact/src/index.ts:33`](../../packages/compact/compact/src/index.ts)
## `ctx.fs` — `FileSystem` (abstract seam)
Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every backend must honor:
- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks).
- stat returns FsInfo metadata (never content) or `undefined` when the target is absent.
- readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`.
- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`.
- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write.
- editText verifies `expected.version` BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`).
```ts cordis-catalog
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
@@ -147,7 +115,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F
Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:78`](../../packages/fs/fs/src/index.ts)
## `ctx.llm` — `LlmService`
@@ -161,37 +129,24 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:72`](../../packages/llm/llm/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
Abstract process-sandbox service. Subclass, implement confine, and load the subclass as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every implementation must honor:
- confine either returns an argv whose runner ENFORCES the policy or fails closed — at `confine` time with SandboxUnavailableError (no backend for this host), or at EXECUTION time by the runner itself refusing to run the command (exiting without exec'ing it, identified by ConfinedArgv.runnerFailureSignatures). A silent unconfined passthrough is never a legal outcome on either path.
- Probing exists to ARBITRATE between multiple candidate backends and may be skipped when a platform has exactly one: the sole candidate is selected directly and the runner's exec-time fail-closed refusal carries the safety property. When probing does run, it is functional (actually enforcing a profile, not a version check), at most once per provider lifetime; `confine` itself spawns nothing beyond that one-time probing.
- The returned ConfinedArgv.enforcement states the backend's actual completeness for THIS host; `partial` is reported, never silently upgraded to `full`.
```ts cordis-catalog
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
```
Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md)
Source: [`packages/sandbox/sandbox/src/index.ts:180`](../../packages/sandbox/sandbox/src/index.ts)
Source: [`packages/sandbox/sandbox/src/index.ts:109`](../../packages/sandbox/sandbox/src/index.ts)
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF):
- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn).
- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object.
- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization).
```ts cordis-catalog
abstract create(meta: SessionHeader): Promise<void>
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
@@ -201,7 +156,7 @@ abstract list(): Promise<SessionHeader[]>
Types: [SessionEvent](../core-data-structures/core.md)
Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts)
Source: [`packages/session-persistence/session-persistence/src/index.ts:61`](../../packages/session-persistence/session-persistence/src/index.ts)
## `ctx.sessions` — `SessionStore`
@@ -220,7 +175,7 @@ list(): Session[]
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
```
Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:333`](../../packages/core/session/src/index.ts)
## `ctx.skills` — `SkillService`
@@ -246,7 +201,7 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun
```
Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:126`](../../packages/subagent/subagent/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt`
@@ -260,13 +215,11 @@ protect(protection: PromptProtection): () => Promise<void> | void
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
```
Source: [`packages/core/system-prompt/src/index.ts:379`](../../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts)
## `ctx.tools` — `ToolRegistry`
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section.
Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree.
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.
```ts cordis-catalog
register(definition: ToolDefinition): () => Promise<void> | void
@@ -281,7 +234,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:481`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:374`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
@@ -314,24 +267,17 @@ async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearc
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
```
Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts)
Source: [`packages/web/web/src/index.ts:79`](../../packages/web/web/src/index.ts)
## `ctx.workflows` — `WorkflowService` (abstract seam)
Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every implementation must honor:
- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation).
- The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles.
- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind).
- Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it.
```ts cordis-catalog
abstract start(request: WorkflowStartRequest): WorkflowRun
```
Source: [`packages/workflow/workflow/src/index.ts:214`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts)
## Inherited `ctx` members (cordis core + loader/hmr/timer)
+1 -1
View File
@@ -89,7 +89,7 @@ interface SubagentProvider {
}
```
The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fulfills and emits the paired `subagent/end` when that started run settles (see the [events catalog](../cordis-catalog/events.md)); a pre-publication readiness rejection emits neither event. For an in-process provider, a start listener can therefore resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s, so a subscriber observes but cannot change the run. Result settlement is observed immediately even while readiness is pending, then its cloned end payload is buffered until start has been announced; this prevents an early rejection from becoming unhandled while preserving start-before-end order and protecting the caller's result from listener mutation. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it.
`subagent/start` follows successful readiness; `subagent/end` follows settlement of that announced run. Readiness rejection emits neither. In-process children can be resolved through the agent registry, while remote providers may have no local agent. End events carry cloned `lastAssistantMessage` on successful settlement and omit it on infrastructure failure. Both events are observe-only, preserve start-before-end order, and contain subscriber exceptions independently. See the [events catalog](../cordis-catalog/events.md) for signatures.
## In-process backends: depth and seed
+37 -37
View File
@@ -7,46 +7,46 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:587`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:419`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:437`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:518`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:533`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:551`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:234`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:306`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:33`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:60`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:69`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:43`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:130`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:136`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:75`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:101`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:45`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:166`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:98`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:87`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:108`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:80`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:49`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:60`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:69`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:30`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:40`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:84`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:115`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:82`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:71`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:92`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:61`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:54`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:46`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
## Non-harness or undeclared event strings seen in package source
+30 -32
View File
@@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo
Types: [CallId](core-data-structures/core.md)
Source: [`packages/ui/user-approval/src/index.ts:86`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:47`](../packages/ui/user-approval/src/index.ts)
#### `approval/decided` — log-only
@@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly
'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
```
Source: [`packages/ui/user-approval/src/index.ts:97`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:58`](../packages/ui/user-approval/src/index.ts)
#### `approval/policy` — log-only
@@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne
'approval/policy': { policy: ApprovalPolicy }
```
Source: [`packages/ui/user-approval/src/index.ts:109`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts)
### `assistant/*`
@@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity.
Types: [StreamChunk](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface
@@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts)
### `bash/*`
@@ -81,7 +81,7 @@ The session's sandbox mode was switched — log-only (like `approval/*`; NOT a s
'bash/sandbox-mode': { mode: SandboxMode }
```
Source: [`packages/bash/bash/src/session-mode.ts:31`](../packages/bash/bash/src/session-mode.ts)
Source: [`packages/bash/bash/src/session-mode.ts:19`](../packages/bash/bash/src/session-mode.ts)
### `compact/*`
@@ -93,7 +93,7 @@ Marks the end of a compaction — log-only, releases the lock. `error` set if su
'compact/end': { turn: number; error?: string }
```
Source: [`packages/compact/compact/src/types.ts:46`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:34`](../packages/compact/compact/src/types.ts)
#### `compact/start` — log-only
@@ -103,7 +103,7 @@ Marks the start of a compaction — log-only, holds the lock until `compact/end`
'compact/start': { turn: number }
```
Source: [`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:11`](../packages/compact/compact/src/types.ts)
#### `compact/summary` — log-only
@@ -115,7 +115,7 @@ Provenance record of a completed summarization — log-only, no surfaceOp. The s
Types: [ContentBlock](core-data-structures/core.md)
Source: [`packages/compact/compact/src/types.ts:30`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:18`](../packages/compact/compact/src/types.ts)
### `context/*`
@@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts)
### `hook/*`
@@ -141,23 +141,23 @@ A hook command was invoked at a hook point — log-only provenance (like `compac
'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string }
```
Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../packages/hooks/hook-protocol/src/types.ts)
Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-protocol/src/types.ts)
#### `hook/result` — log-only
A hook command's outcome — log-only, paired with a prior `hook/invoked` (same `handlerId`). `decision` is the dialect-neutral outcome derived by `appendHookResult` (which owns the rule): the hook's parsed decision (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to halt via `continue:false`, else `'pass'`. `exitCode` is the process exit (absent if it never ran), `stderrSummary` the trimmed stderr truncated to the bridge's configured cap (the block reason source on exit 2), `durationMs` the wall-clock runtime (audit timing; snapshot replay normalizes it). `turn` matches the `hook/invoked`.
Log-only hook outcome paired to `hook/invoked` by `handlerId`.
```ts persistence-catalog
'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number }
```
Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook-protocol/src/types.ts)
Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../packages/hooks/hook-protocol/src/types.ts)
### `prompt/*`
#### `prompt/blocked` — log-only
A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. Appended in place of the `user/message` the prompt would have become, so the block survives replay even in a MIXED batch where another queued prompt is allowed (there the turn does not end `rejected`, so the boundary reason alone would not preserve it). `content` is the original prompt the listener rejected; `reason` is the veto text (PromptDecision `block.reason`). NOT a SurfaceEventType: a blocked prompt produces no LLM message and never reaches `deriveMessages()`.
A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why.
```ts persistence-catalog
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
@@ -165,29 +165,29 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
### `request/*`
#### `request/header` — log-only
Full snapshot of the EpochHeader the NEXT request is built under, with the RequestHeaderReason it was recorded whole. Appended by the loop inside the step, before dispatch, on a loop instance's first request-building step (`'initial'`/`'resume'`) or when a delta failed its round-trip guard (`'fallback'`); always records what the request actually used, post-`agent/request`. Anchors the header fold: reconstruction reads the latest snapshot and applies the deltas after it. NOT a SurfaceEventType: it produces no LLM message — it is the request envelope, logged so every request is a pure function of the session log (the reconstructability RFC).
Full snapshot of the EpochHeader the NEXT request is built under, with the RequestHeaderReason it was recorded whole.
```ts persistence-catalog
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
#### `request/header-delta` — log-only
Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field — the loop never produces one in practice: the prefix is composed once per instance and anchored by that instance's snapshot, so this arm exists for codec totality). Appended by the loop inside the step, before dispatch, when the header for this request differs from the fold of the log so far; the writer verifies `applyHeaderDelta(previous, delta)` reproduces the new header exactly and falls back to a `'fallback'` `request/header` snapshot when it cannot, so a logged delta ALWAYS round-trips. NOT a SurfaceEventType.
Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field — the loop never produces one in practice: the prefix is composed once per instance and anchored by that instance's snapshot, so this arm exists for codec totality).
```ts persistence-catalog
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
```
Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
### `steering/*`
@@ -201,7 +201,7 @@ Steering content injected between steps of a running turn.
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
### `step/*`
@@ -213,7 +213,7 @@ Closes step `step` of turn `turn`.
'step/end': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts)
#### `step/start` — log-only
@@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
'step/start': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
### `todo/*`
@@ -231,15 +231,13 @@ Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/
The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`.
NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — it is durable, replayable UI state, distinct from the conversation history. It is a `SessionEventMap` member riding the existing `session/event` emit, not a first-class Cordis `interface Events` notification, so it has no cordis-catalog row.
```ts persistence-catalog
'todo/write': { todos: TodoItem[] }
```
Types: [TodoItem](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
### `tool/*`
@@ -253,11 +251,11 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction.
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text.
```ts persistence-catalog
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
@@ -265,7 +263,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i
Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/tools/src/code-mode.ts:38`](../packages/core/tools/src/code-mode.ts)
Source: [`packages/core/tools/src/code-mode.ts:23`](../packages/core/tools/src/code-mode.ts)
#### `tool/result` — surface
@@ -277,7 +275,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts)
### `turn/*`
@@ -291,7 +289,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
Types: [TurnEndReason](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only
@@ -303,7 +301,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
Types: [TurnTrigger](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts)
### `user/*`
@@ -317,4 +315,4 @@ A user-visible prompt (queued message drained at turn start).
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
+3 -7
View File
@@ -1,15 +1,11 @@
# AGENTS.md — Implemented RFCs
These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type safety and documentation", [docs/AGENTS.md](../../AGENTS.md)), and the in-file skeleton — including the proposal→implemented rewrite a lifecycle move owes — is [README.md § The file format](../README.md#the-file-format), gated by `verify-rfc-format`; this file adds one rule specific to this folder.
These RFCs describe shipped decisions. Follow the repo and docs standards plus the [RFC format](../README.md#the-file-format).
## Keep an implemented RFC current with what actually shipped
An RFC in `implemented/` describes a decision that is now **live code**. Keep its description of the shipped reality accurate: when the implementation later moves a file, renames a package or symbol, changes a config key/default/error code, or relocates a plugin, update the RFC in the **same change** that touches the code — exactly as you would a package README. A stale implemented RFC (pointing at a path that no longer exists, naming a package that was renamed, describing a structure that was refactored) is worse than no RFC: a future reader trusts it and is misled.
Update it **in place** to state the current truth. Do **not** leave the outdated text in and bolt on a "superseded / now actually…" note — that makes the document a changelog of its own drift and forces the reader to reconstruct the present from a pile of corrections. Write what is true now.
Keep paths, symbols, defaults, and mechanisms current in the same change that alters them. Rewrite stale facts in place; do not append change history.
### This is not a license to rewrite the *decision*
Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision").
When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating.
Update factual realization in place. A reversal of the decision or its rationale requires a new RFC and cross-link; see [rfc/README.md](../README.md).
@@ -22,7 +22,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
### 3. Bash owner token in the seam
Background-task ownership moved from a `tool-bash` plugin-local `Map<string, Agent>` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.session.header.id` as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.session.header.id === ownerToken` (read via `ctx.get``onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped.
## Verification
@@ -6,7 +6,7 @@ Status: implemented
The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs.
**These fields are NOT a new security boundary.** It is tempting to frame arbitrary-stdin / arbitrary-env as "dangerous, so gate who may use them" — but that framing is wrong, because a model driving the `bash` tool **already** has equivalent power through ordinary shell syntax: `FOO=bar cmd` sets an env var, a heredoc or `printf … | cmd` feeds arbitrary stdin. Adding `env`/`stdin` as seam fields grants the model no capability it lacks. In particular they cannot exfiltrate the harness's ambient credentials: the real control for that is the **credential scrub** in [dsh-bash-local](../../../../packages/bash/bash-local)'s `childEnv()`, which strips `*KEY*`/`*SECRET*`/`*TOKEN*` from `process.env` before the child sees it (see [docs/defensive-patterns.md](../../../defensive-patterns.md) § "Never hand untrusted output the ambient environment or predictable paths"). The scrub works regardless of these fields — a model cannot read a value that is not in the environment, and tool-call arguments are static JSON, never shell-evaluated, so a model cannot write `env: {LEAK: $DEEPSEEK_API_KEY}` and have it expand. So the security question is already answered by the scrub; this RFC is only about giving trusted in-process callers a clean way to pass a JSON payload + `CLAUDE_*` vars without routing them through model-visible shell text.
`stdin` and `env` do not create a new model capability because ordinary shell syntax already supplies both. Ambient credentials are protected by `dsh-bash-local`'s child-environment scrub, not by hiding these seam fields; model tool arguments are static JSON and do not expand shell variables. The fields therefore serve trusted in-process callers, such as hook bridges, that need to pass structured input and `CLAUDE_*` variables without embedding them in model-visible shell text. See [defensive-patterns.md](../../../defensive-patterns.md) for the ambient-environment rule.
## Decision
@@ -20,9 +20,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
**Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace``SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds.
`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot; `request/header-delta` encodes supported changes. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot when necessary.
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed.
Each step rebuilds the prompt assembly, composes and freezes the session prefix once per loop instance, runs `agent/pre-step`, snapshots derived messages immediately before `step/start`, and folds call config from the logged header. `agent/request` may replace only the frozen config seed; model-visible content must enter through logged channels. The loop then records the owed header event, builds `GenerateOptions` from the prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written.
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
@@ -57,7 +57,7 @@ export function deadline(
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined
```
`deadline` is `AbortSignal.any([upstream, <timeout controller>])` with three things the standard library does not give: a typed, identifiable `TimeoutReason` on the timeout abort (native `AbortSignal.timeout()` yields a fixed `TimeoutError`, indistinguishable across timeout kinds), an internal `timeoutMs <= 0` "no timeout" sentinel for backend-owned background work, and a `Symbol.dispose` cleanup that works with both `using` and manual disposal. `AbortSignal.any` is a Node ≥ 20 primitive; it is the single mechanism that fuses two abort sources into one, adopting the reason of whichever fires first. External request hints validate as positive finite numbers via `clampTimeout` before they reach `deadline`; `0` is not a model-/plugin-facing "disable timeout" value. When `timeoutMs <= 0` and no upstream signal is present, `deadline()` returns a never-aborting signal plus a no-op disposer so callers keep one call shape. `TimeoutReason` is an internal classification reason: providers translate it into seam-specific public errors or result fields before returning to callers. `timeoutOf`'s optional `code` scopes classification to the caller's own deadline: when the `upstream` is itself a deadline (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if it fires first, and an unscoped match would misreport the outer timeout as the inner capability's own; scoping to `code` reads a foreign timeout as an ordinary upstream cancel.
`deadline` fuses an upstream signal with a timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout.
### The division of labor
@@ -4,731 +4,167 @@ Status: implemented
## Problem
One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface.
One application can run many agents that share infrastructure but need different capabilities and policy. A child may have its own persona, tool set, structured-output schema, and listeners while still using the deployment's model adapters, persistence, and UI.
This is a composition problem, not an application-isolation problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little.
Neither a global registry nor a separate service graph per agent fits that shape. Global registration leaks child-specific behavior; independent graphs duplicate shared services and make cross-agent infrastructure harder to compose.
| Surface | What varies by agent | Failure when it is only global |
|---|---|---|
| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt |
| Prompt state | Persona, instructions, variables, and Code Mode SDK declarations | Every agent receives the same instructions or runtime facts |
| Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work |
| Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles |
The model-visible, executable, and observable views must agree. A hidden tool must not remain callable, an advertised tool must execute through the same scoped definition, and policy intended for one agent must not intercept another. The registrations must also disappear only after their agent reaches quiescence.
Two consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation.
Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo.
The subagent API makes both needs concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins.
Some owner rules cannot depend on middleware order. Prompt assembly, tool policy, result transformation, and continuation are extensible waterfalls, so another listener can wrap, replace, or short-circuit ordinary listeners. Structured output and reserved transport need service-owned final boundaries.
## Decision
Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned.
Each live agent owns a Cordis registration context, `agent.ctx`. Registering through a plain plugin context contributes to the deployment; registering through `agent.ctx` contributes only to that agent and is disposed with it.
The design has three parts:
| Part | Rule | Purpose |
|---|---|---|
| Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners |
| Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup |
| Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order |
| Part | Contract |
|---|---|
| Registration scope | Resolve the global layer plus exactly one agent layer; the registration context determines both visibility and ownership. |
| Lifecycle transaction | Compose the scope while the agent and session are unpublished, then publish through an ordered rollback-covered sequence. |
| Owner-final policy | Services provide narrow final boundaries for canonical prompt entries, monotonic tool denial, authoritative results, and terminal turn stopping. |
The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority.
Scopes are flat. A child does not inherit its parent's scoped registrations; parentage is explicit session data, and an ownership link controls lifetime without granting authority.
The implementation lives primarily in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape.
The public contracts live in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The generated [event catalog](../../../cordis-catalog/events.md) is the signature reference.
## Background: the small Cordis vocabulary used here
## Registration scope
The design relies on four framework ideas: contexts, effects, waterfall events, and dispatch receivers. This section gives the complete mental model needed for the rest of the RFC; the [Cordis primer](../../../cordis-primer.md) covers the framework more broadly.
### Context selects visibility and ownership
### A context is both a service view and a registration origin
A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API.
A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context.
### Effects give registrations an owner
A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload.
`dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context.
### A waterfall is ordered around-middleware
A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it.
This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all.
### The dispatch receiver selects scoped listeners
Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents.
This receiver is live coordination state, not a durable session fact. The distinction matters later: `tools/result` is a live final-outcome notification, while the similarly named `tool/result` is an append-only session event stored for replay and model history.
## Agent-scoped registrations
An agent scope couples two facts that must not drift apart: who can see a registration and who disposes it. The calling context determines both facts, leaving the domain-specific merge rules to each registry.
### The resolution model is global plus exactly one scope
Every scope-aware registry keeps a global layer and per-scope layers. Resolving for agent A combines the global layer with A's layer only; it does not walk A's parent lineage or combine sibling scopes.
A Cordis context is both a service view and the origin of effects such as tool registration, prompt contribution, and event subscription. `createScope(ctx, key)` mounts an ownership fiber and returns a derived context tagged with an opaque `ScopeKey`; derived contexts inherit the nearest tag.
| Registration origin | Visible to | Disposed with |
|---|---|---|
| Plain plugin context | Every agent | The registering plugin |
| `agent.ctx` | That agent only | That agent's scope |
| Plain plugin context | Every agent | Registering plugin |
| `agent.ctx` | That agent | Agent scope |
Named scoped contributions shadow a same-named global contribution. A child persona is therefore a scoped `deployment:persona` section, and a per-agent tool implementation can keep the same model-facing name. Duplicate names within one layer still fail loudly. The deliberate exception is a globally protected prompt-section name, whose owner reserves it against scoped shadowing.
This coupling prevents a registration from being visible to one agent but owned by an unrelated lifecycle. The live `Agent` object is its scope key, so operations that already carry the agent need no secondary string lookup.
The plugin-facing mechanism is the same API called through a different context. In language-neutral pseudocode:
`agent.ctx.agent` is a convenient association, not the generic scope tag. Lower-level services use `scopeOf(context)` because a nested scope may replace the nearest key while retaining inherited context properties.
```text
# Deployment-wide contribution
appContext.tools.register(readTool)
The scope exposes two disposal forms. `rawDispose` is the exact Cordis disposer required when nesting a scope at a precise generator-effect position; `dispose()` is the idempotent promise ordinary callers use to await the backing fiber's quiescence, including a race started through `rawDispose`.
# Contribution visible only to agent A and disposed with A
agentA.ctx.tools.register(childOnlyTool)
### Registries retain domain-specific merge rules
resolveTools(agent A):
visible = copy(globalTools allowed by A's restrictions)
visible.overlay(tools registered through A.ctx)
visible.append(reserved presentation transport, when configured)
return visible
```
The scope primitive selects a layer but does not prescribe how a service combines it. Named tools, prompt sections, and variables use scoped-over-global shadowing; tool-schema providers are additive within the selected view. Duplicate names in one layer fail.
There is no `for each ancestor` step. Resolving for A never reads the parent or sibling layers.
Reads name their subject explicitly. Prompt assembly receives an `AssembleContext.scope`; tool lookup, visibility, execution, timeout policy, Code Mode bindings, inspection, and presentation receive an agent or scope. Merely calling a read method through `agent.ctx` does not silently choose a subject.
The scope key is an opaque object compared by identity. The harness uses the live `Agent` object as its own key, so event payloads, tool executions, and prompt assemblies that already carry the agent can select the correct layer without translating through a string ID that may later be reused.
Tool restrictions mask global end capabilities for one agent, and multiple restrictions intersect. Tools registered in the agent's own layer are explicit grants. A hidden global tool behaves as unknown at execution.
### `agent.ctx.agent` is an association, not the scope resolver
Code Mode's `run_code` is reserved transport rather than an end capability. It remains outside the filterable layers so a restriction cannot leave an SDK in the prompt without its only transport. The registry resolves restricted globals plus scoped grants, then adds the transport in non-native modes; every registry-owned view consumes that same result.
`agent.ctx` carries an own `agent` property for setup code and plugin ergonomics. Contexts derived from it inherit that association, while a plain context reads `undefined`.
### Scoped events use the operation's subject
The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package.
A scoped event reaches global listeners and listeners registered through the matching agent context. It never reaches another agent's listeners. Cordis's explicit `{ global: true }` option remains the intentional bypass.
### The scope primitive has separate public and composite disposal forms
`dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight.
| Operation | Responsibility |
|---|---|
| `createScope(context, key)` | Mount the ownership fiber and return its tagged derived context |
| `scopeOf(context)` | Read the nearest inherited scope key |
| `scopeTarget(subject, key)` | Build the receiver used for scope-filtered dispatch |
| `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence |
| `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position |
The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope.
The primitive itself is small. Its essential implementation shape is:
```text
createScope(parentContext, key):
fiber = mount no-op plugin under parentContext
scopedContext = derive fiber.context with nearest-scope-tag = key
rawDispose = fiber's exact disposer
dispose = memoized operation that:
invoke rawDispose if it has not started
follow fiber's in-flight teardown until quiescent
return { ctx: scopedContext, rawDispose, dispose }
```
Derived contexts inherit the nearest scope tag. Mounting an ordinary plugin under `agent.ctx` therefore preserves the agent's scope, while deliberately creating another scope replaces the tag for registrations below it.
### Registry resolution stays domain-specific
The shared primitive answers “which layer?” and “who owns cleanup?” but does not force every service to merge data the same way. Tools, prompt sections, variables, and tool-schema providers retain rules appropriate to their domains.
Prompt sections, prompt variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive, but a provider registered through `agent.ctx` participates only in that agent's assemblies. Read operations name the subject explicitly: tool lookup and execution receive an agent or scope, and prompt assembly receives an `AssembleContext` whose `scope` selects the layer.
Calling a service through `agent.ctx` does not implicitly make every later read agent-scoped. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global layer. This keeps shared services able to operate on behalf of any subject and makes the subject visible at the read or execution call site.
### Tool registrations are frozen snapshots
The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects.
Tool parameters cross the model and log boundary, so the registry requires them to be lossless JSON before cloning and validates the clone again to contain unstable getters. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections.
```text
registerTool(context, definition):
require definition.parameters is lossless JSON
parameters = clone(definition.parameters)
require parameters is still lossless JSON
stored = deepFreeze({
copied name, description, timeout,
parameters,
execute: bind definition.execute to definition,
presentation callbacks: bind once when present
})
layerFor(scopeOf(context)).add(stored.name, stored)
```
The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers.
### Tool restrictions reduce end capabilities without removing transport
A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface.
The restriction snapshots its input, rejects an empty filter, and validates named tools against the pre-restriction capability universe. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation.
[Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it.
The registry still uses one executable visibility view. It first resolves restricted global capabilities plus scoped grants, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view.
The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions.
`knownNames` serves a narrower configuration purpose: it is the pre-restriction end-capability universe used to distinguish a typo from a deliberately hidden tool. The system-prompt provider adds presentation names when validating `toolOrder`: `code` mode accepts only `run_code`, `both` accepts end capabilities plus `run_code`, and a per-agent restriction may remove a known capability from one assembly without turning the deployment's order configuration into an error.
## Scoped event delivery
Scoped registration is incomplete unless behavior follows the same boundary. An event about agent A reaches global listeners and A-scoped listeners, never listeners installed for B.
### Delivery is global plus the matching scope
The dispatch receiver carries the operation's scope key. Its filter admits an unscoped listener or a listener registered through the matching scoped context, while a subject-less dispatch admits unscoped listeners only. Cordis's explicit `{ global: true }` listener option remains the intentional bypass for infrastructure that must observe every dispatch.
Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, `skill/provider-*`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes.
### Each event family derives its key from its real subject
The operation being described determines the key; callers cannot attach an unrelated scope. Fused helpers and store-owned carriers keep the payload subject and delivery subject together.
The dispatch receiver carries the scope key and is exposed as `this` to function listeners. Each event family derives the key from its real subject rather than accepting an independent caller-supplied scope:
| Event family | Scope source |
|---|---|
| `agent/*`, including `agent/turn-stop` | The event's agent |
| `approval/request` | `ApprovalRequest.agent` |
| `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call |
| `agent/*` | Event agent |
| `approval/request` | `request.agent` |
| Tool execution events | `execution.agent` |
| `system-prompt/assemble` | `AssembleContext.scope` |
| `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store |
| `subagent/start`, `subagent/end` | The delegating parent agent |
| Session events and flushes | Owner captured when the session enters the store |
| `subagent/start` and `subagent/end` | Delegating parent |
Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners.
Registry-membership notifications remain unfiltered because they describe shared registry state rather than one agent's operation.
The dispatch rule can be read independently of Cordis internals:
The receiver is a proxy over the subject. Property access and writes reach the subject, and methods bind to the subject so classes with private fields work; the proxy is intentionally not identity-equal to it. Event arguments carry the real object where identity matters. `Scoped<T>` marks the required receiver at typed dispatch sites, while runtime marks and development invariants cover JavaScript and casts.
```text
dispatchScoped(subject, scopeKey, event, arguments):
carrier = proxy(subject, tag = scopeKey)
## Agent lifecycle transaction
for listener in listeners(event):
if listener has no scope tag or requests the explicit global bypass:
call listener with this = carrier
else if listener.scopeTag == scopeKey:
call listener with this = carrier
else:
skip listener
```
### Setup finishes before publication
The real helpers fuse values that must agree. `agentEvents(context, agent)` uses the same agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available.
Create and resume reserve both agent and session IDs before any await, snapshot caller-owned options and setup inputs, construct the agent, mint its scope, and install the teardown skeleton. Resume also races persistence loading against owner disposal so a late backend result cannot publish after its owner is gone.
### The carrier behaves like the subject but has distinct identity
The optional `setup(agentCtx)` callback runs while neither the session nor the agent is globally visible. It may register scoped contributions or await child-plugin activation. A rejection, owner unload, or failed liveness check unwinds the complete unpublished world and releases both IDs.
Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it.
Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The proxy preserves the subject's existing event filter and JavaScript object invariants, but it is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters.
`Scoped<T>` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches.
## Agent creation and teardown
An agent's scope, session, registry entry, and driver form one owned transaction. Setup finishes before publication, publication is synchronous and rollback-covered rather than magically atomic, and teardown reaches one ordered quiescent boundary.
### Create and resume reserve identities before asynchronous work
Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup.
The factory captures IDs and the setup callback and clones caller-owned agent options, session metadata, and seed events before the first asynchronous boundary. Resume does the same before persistence loading. A caller mutating its options object later therefore cannot move the transaction away from the identities it reserved or change the configuration eventually published.
Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path.
Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap.
The sentinel exists only for the interval in which no agent lifecycle can exist yet:
```text
resume(request):
snapshot request ids, options, and setup callback
sentinel = owner.effect(onDispose => signal ownerDisposed)
reserve(agentId, sessionId)
try:
persisted = await firstOf(persistence.load(sessionId), ownerDisposed)
session = reconstruct(persisted)
# This call installs the full lifecycle before its first await.
starting = startOwned(agentId, session, options, setup)
disarm and dispose sentinel
return await starting
finally:
release both ids
settle the sentinel transaction
```
If `ownerDisposed` wins, the load promise may continue inside the backend, but it has no path back to publication.
### Setup composes an unpublished world
The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it.
Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If the owner unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish.
After setup settles, the factory yields one microtask checkpoint and rechecks the lifecycle flag, owner-fiber state, and owning agent's disposed state. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit owner checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent.
Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists.
The common create/resume tail makes the unpublished boundary explicit:
```text
startOwned(snapshot, preparedSession):
world = prepareLifecycle(snapshot, preparedSession)
# world now owns agent.ctx and the complete rollback/teardown skeleton
try:
await firstOf(snapshot.setup(world.agent.ctx), world.deactivated)
await oneMicrotask()
require world.lifecycleActive
require world.ownerFiberActive
require world.ownerAgentNotDisposed
world.publish(snapshot.source)
return handle(world.agent, world.dispose)
catch error:
await world.dispose()
throw error
```
`setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer.
Setup code can reach the unpublished agent through `agentCtx.agent`, but the driver cannot accept work until publication enables its private controls. This keeps the first turn behind the lifecycle boundary without publishing a partially configured agent.
### Publication is ordered and rollback-covered
After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps:
After setup, publication runs synchronously in this order: enter the session store, enter the agent registry, announce `session/created`, announce `agent/created`, enable driving, emit the contained `agent/session-start` notification, and start the loop.
1. Enter the session store and capture its scope carrier.
2. Enter the agent registry without announcing it.
3. Emit `session/created`.
4. Emit `agent/created`.
5. Enable driving.
6. Emit `agent/session-start`.
7. Start the driver loop.
Both registry entries exist before creation listeners run. The sequence is not atomic: observers run during it, and rollback cannot retract effects they already performed. A throwing creation listener causes the owned transaction to unwind; failures from the non-vetoing session-start notification are reported without preventing loop startup.
The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction:
### Teardown preserves the scoped world until work settles
```text
publish(world):
world.detachSession = world.agent.ctx.sessions.enter(world.session)
world.detachAgent = app.agents.enter(world.agent)
app.sessions.announce(world.session)
app.agents.announce(world.agent)
world.driver.enableDrivingVerbs()
emitNonVetoing(agent/session-start)
world.stopDriver = world.driver.start()
```
Every owner path stops and awaits the driver and agent-started durability checkpoints, removes the agent, detaches the session, then unwinds the scope. Final session events and flushes therefore still see the session and scoped listeners. `AgentHandle.dispose()` and `Scope.dispose()` give racing callers shared quiescence boundaries.
Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work.
In-process subagents add a run-owner fiber under `parent.ctx`. This makes the parent own the child lifecycle without merging the parent's scoped capabilities into the child's new flat scope.
The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. An announced agent is paired with its disposal notification during rollback. `agent/session-start` is a non-vetoing notification: listener failures are logged and contained so the loop still starts.
## Owner-final policy
### Teardown stops work before revoking its world
Ordinary waterfalls remain the extension mechanism. The following service-owned boundaries are reserved for invariants whose result must not depend on listener order:
Every owner path uses the same reverse order: stop the loop and await its actual exit plus every agent-started durability checkpoint, remove the agent from the registry, detach the session, then unwind the scope. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live.
| Boundary | Guarantee |
|---|---|
| `systemPrompt.protect()` | After the assembly waterfall, restore the canonical presence, definition, and local anchor of named sections or schemas. Canonical absence is protected too. |
| `tools.guard()` | After pre-execute policy, guards may deny or abstain but cannot allow, so denials compose monotonically. |
| `tools/result` | After execution, post-processing, error normalization, and JSON validation, notify observers of one immutable authoritative outcome. Observer failures are contained independently. |
| `agent/turn-stop` | After ordinary continuation and steering folding, a strict serial stop is terminal through turn close and flush; it discards steering but preserves queued prompts. |
```text
disposeOwnedAgent(world):
await world.stopDriver() # waits for loop exit and all agent-started flushes
world.detachAgent() # emits agent/disposed when announced
world.detachSession()
await world.scope.dispose()
```
Prompt protection is narrow rather than a whole-assembly reset. It removes protected names from the transformed result and reinserts canonical entries near their surviving canonical neighbors; unrelated contributions remain extensible. A globally protected section name cannot be shadowed by a scoped section. Code Mode protects its SDK section and `run_code`; structured output protects its capture instruction and schema.
The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown.
Tool execution identity supports the final-result boundary. The registry snapshots lossless-JSON arguments into a distinct execution, assigns an opaque frozen token, and makes identity fields immutable before policy. Only `signal` remains replaceable by around-dispatch wrappers. Nested transports carry the parent's token, not its live execution object.
`agent/disposed` means the driver is quiescent and the agent has left the registry; session detachment and scope unwind may still be completing after that notification. `AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races.
`tools/result` is a live registry notification and also fires for programmatic execution. The singular `tool/result` session event is the durable transcript record appended later by the loop. Consumers choose the live final verdict or persisted history according to their contract.
Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities.
## Owner-final policy boundaries
Cooperative waterfalls remain the general extension mechanism, but an invariant belongs after the last transformable point. The design adds four narrow boundaries, each owned by the service that can define what “final” means.
### Prompt protection restores named canonical contributions
`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails.
For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence.
A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas.
Restoration is intentionally not a whole-assembly reset. The service first removes protected names from the waterfall result, then reinserts protected canonical entries in their canonical order immediately before the first surviving later unprotected canonical neighbor, or at the end when no such neighbor survives. Unprotected entries keep the ordering and definitions chosen by the waterfall. This anchor rule preserves the protected contribution's meaningful local placement without claiming that protection restores every global relative position after arbitrary listener reordering.
```text
registerSection(input, scope):
stored = copy(input.name, input.order, input.text)
if scope exists and stored.name is globally protected:
fail before registration
sectionLayer(scope).add(stored)
assemble(context):
canonical = assemble registries for context.scope
transformed = await systemPromptAssembleWaterfall(clone(canonical))
for each protected name:
remove every transformed entry with that name
if canonical contains the name:
if a later unprotected canonical neighbor survived:
insert the canonical entry before that neighbor
else:
append the canonical entry
return transformed
```
This algorithm restores a protected entry's definition, presence or absence, and useful local anchor without erasing unrelated listener output.
Code Mode uses global protection for the `tools:sdk` section and reserved `run_code` schema. Structured output adds scoped protection for its instruction and capture schema. These are named guarantees: unrelated listeners may still contribute unrelated sections or tools.
### Tool executions have stable identity
`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. The registry requires `arguments` to be losslessly JSON-serializable, validates before cloning and again after cloning to contain unstable accessors, then deep-freezes the detached value. A cloneable but mutable exotic such as `Map` is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification.
The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation.
Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID.
For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper.
The input-to-execution conversion is intentionally one-way:
```text
prepareExecution(input):
require input.parent is absent or a registry-minted token
require input.arguments is lossless JSON
detachedArguments = clone(input.arguments)
require detachedArguments is still lossless JSON
execution = {
token: new frozen property-free object,
callId: input.callId,
name: input.name,
arguments: deepFreeze(detachedArguments),
agent: input.agent,
parent: input.parent,
signal: input.signal
}
make every field except signal non-writable and non-configurable
return execution
```
### Tool guards can deny but never re-allow
`ctx.tools.guard()` installs a synchronous global or scope-specific guard after the extensible `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result.
This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions; an ask resolves through the optional `ctx.approval` seam, where only `allowed-once` becomes allow and an absent channel or any non-grant becomes deny before guards run. No listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome.
### `tools/result` observes the authoritative live outcome
The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute``tools/post-execute``tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. Immediately before that boundary, the registry validates that the entire authoritative result can round-trip losslessly through JSON; an invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log.
Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`.
`tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter.
The entire registry method reads like one authority ladder:
```text
execute(input):
try:
execution = prepareExecution(input)
catch invalidInput:
execution = frozen identity shell with arguments = undefined
result = errorResult(invalidInput)
await tools/result observers with independent failure containment
return result
try:
gate = await tools/pre-execute(execution)
decision = gate
if gate asks:
decision = await resolveWithApproval(gate, execution.agent)
# approval absence and every non-grant resolve to deny
if decision allows:
denial = firstRegisteredGuardDenial(execution)
else:
denial = decision.denial
if denial exists:
result = errorResult(denial)
else:
result = await tools/execute(execution, next = dispatchRegisteredTool)
result = requireValidExecutionResult(result)
result = await tools/post-execute(execution, result)
result = requireLosslessJson(result)
catch pipelineFailure:
result = errorResult(pipelineFailure)
freeze(execution)
frozenResult = deepFreeze(clone(result))
await every tools/result observer independently, containing each failure
return result
```
Waterfalls can transform only at their named stages. Guards can only deny, and the final observers can only observe.
### `agent/turn-stop` makes a composed continuation terminal
Ordinary continuation remains extensible. The loop computes a default, runs the `agent/turn-continuation` waterfall, records any force-continue reason as steering, and folds pending steering into the decision because steering normally demands another model step.
The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work.
Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The authority is reserved for protocols, such as a completed structured child, where further model work would violate the result contract.
```text
afterSuccessfulStep(turn):
decision = await agent/turn-continuation(defaultDecision)
record decision.reason as steering when present
if steering is pending: decision = continue
terminal = await strictSerial(agent/turn-stop)
# undefined means abstain; null, false, malformed values, and throws are errors
if terminal == stop:
discard steering
terminalStopped = true
decision = stop
append turn/end
await session/flush
if terminalStopped:
discard steering added by turn/end or flush listeners
else:
move leftover steering to the next-turn queue
```
The queued-prompt FIFO is separate and is never drained by terminal stop.
`agent/turn-stop` has stronger authority than ordinary continuation and is intended only for terminal protocols. `undefined` is its sole abstention value; malformed returns and listener failures end the current turn as errors. Once stopped, steering added during turn close or flush cannot create another step or fallback turn.
## Subagent composition
In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it.
The in-process spawn and fork providers build a child through the unpublished setup transaction. Spawn uses an empty session; fork seeds only the parent's balanced completed-turn prefix, excluding the currently open tool-call turn.
### Inputs and ownership are fixed before asynchronous creation
Provider definitions and accepted requests are snapshotted before asynchronous creation. Identity capabilities such as the parent and abort signal are retained; mutable options, filters, seed events, schema, and prompt are detached. One run-owner fiber coordinates provider unload, parent teardown, manual disposal, and cancellation during creation.
Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key.
`SubagentRun.started` separates acceptance from publication. It resolves only after the child is in the agent registry and rejects if rollback prevents publication. Lifecycle notifications and workflow bridges wait for this boundary, while attaching result handlers immediately so an early settlement is not unhandled.
Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent.
Persona, tool restriction, and structured output are ordinary registrations installed through the child's context during setup. The child's scope owns them and prevents concurrent children with different schemas or policy from interacting.
The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view.
### Structured output is a terminal protocol
The returned run separates acceptance from publication with `started: Promise<void>`. For spawn and fork, it fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(run.id)` already live; it rejects when rollback prevents publication. The service observes `result` immediately but buffers its cloned end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt.
A structured child receives a scoped `structured_output` tool with its actual schema and a protected prompt instruction. Native mode exposes the tool directly; Code Mode exposes it through the protected SDK and `run_code` transport; both mode offers both paths.
```text
startInProcessRun(providerContext, acceptedRequest):
snapshot all request data, including parent identity
The capture tool validates and stages a cloned value by immutable `ToolExecution` identity. A scoped `tools/result` observer commits it only if that execution's authoritative result succeeds. For a Code Mode sub-call, the value remains pending until the enclosing `run_code` token also reaches a successful final result, so an inner success cannot survive outer runtime or policy failure.
providerLink = providerContext.effect(onDispose => disposeRunOwner())
attach snapshot.abortSignal listener
runOwner = mount no-op plugin under snapshot.parent.ctx
returnedRun.dispose = () =>:
dispose providerLink
await disposeRunOwner()
creation = runOwner.ctx.agents.create({
fresh ids and lineage,
cloned options and optional seed,
setup(childCtx) => install persona, tool restriction, structured runtime
})
returnedRun.started = creation.then(childHandle => publication complete)
returnedRun.result = async:
await returnedRun.started
send the child prompt, await idle, derive the terminal result
SubagentService.start(...):
attach result settlement handlers immediately
await returnedRun.started
emit subagent/start; later emit the buffered or eventual subagent/end
Workflow worker bridge after receiving returnedRun:
register the run so cancellation can reach pre-publication work
attach result settlement handlers immediately and snapshot the outcome
if returnedRun.started fulfills:
send ChildStarted; then send the buffered or eventual outcome
else:
send ChildStartError and dispose the attempt
```
Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, and ensures `workflow/agent-start` never names an unpublished child.
Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers.
### Persona, filtering, and lifetime use ordinary registrations
A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence.
The child's persona, filter, and structured runtime are installed inside factory setup. The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's capability layer into the child.
### Structured output is a child-owned terminal protocol
A structured child registers a real-schema `structured_output` tool and its instruction through its own context. Concurrent children can use different schemas because each scope resolves its own definition, with no global placeholder, reference count, or remove-for-everyone-else pass.
Presentation mode changes where the model invokes the capture capability, but not which child owns it:
| Tool mode | Registry's canonical wire contribution | Generated SDK | Structured-output guarantee |
|---|---|---|---|
| `native` | Visible end-capability schemas, including scoped `structured_output` | None | Protection restores the capture schema and instruction |
| `code` | Reserved `run_code` transport | Visible end-capability bindings, including `structured_output` | Protection keeps `run_code` and the SDK present, keeps native `structured_output` absent from the wire, and restores the instruction |
| `both` | Visible native schemas plus reserved `run_code` | Visible end-capability bindings, including `structured_output` | The model may call the protected capture capability natively or through the protected transport |
The table describes the registry's named canonical contribution. An unrelated assembly listener may deliberately add another schema; protection does not erase unrelated names.
### Capture uses stage, final commit, monotonic denial, and terminal stop
The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the immutable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn.
The scoped `tools/result` observer commits a direct native capture only when that exact execution's authoritative final result succeeds. A later call with a reused string call ID cannot reach the weak-keyed stage, and a post-execution block cannot promote it.
```text
# Native structured-output call
structured_output.body(value, execution):
validate value against this child's schema
staged[execution] = clone(value)
return ordinary success
on tools/result(execution, finalResult):
if execution is staged:
value = staged.remove(execution)
if finalResult succeeded:
captured = value
```
For a Code Mode SDK call, successful inner observation records a pending value against the child execution's opaque `parent` token instead of committing immediately. When the enclosing `run_code` reaches its own `tools/result`, the observer compares that pending token with the outer execution's `token` and commits only on success. A program error or outer post-policy block discards the pending value. This extra boundary is necessary because an inner side effect can succeed while the transport that is supposed to deliver the structured answer still fails.
```text
# Code Mode adds an outer transport commit
on tools/result(innerStructuredCall, innerResult):
if innerStructuredCall is staged:
value = staged.remove(innerStructuredCall)
if innerResult succeeded:
pending = { outerToken: innerStructuredCall.parent, value }
on tools/result(outerRunCodeCall, outerResult):
if pending.outerToken == outerRunCodeCall.token:
value = pending.value
pending = none
if outerResult succeeded:
captured = value
```
The native path has one final-result commit; Code Mode has two because the inner capability and outer transport can fail independently.
Once a value is captured or pending on its outer transport, the scoped `ToolGuard` denies later calls in the same response. After a committed capture, the scoped `agent/turn-stop` ends the turn after ordinary continuation and steering have been folded. Together these boundaries prevent post-capture side effects and prevent a successful tool call from purchasing an otherwise automatic extra model step.
The provider does not re-prompt a child that finishes without a committed capture. Such a run returns an error result with no `structured` value; requesting an output schema creates a requirement, not a guarantee that a failed child produces a value.
Once a value is pending or committed, a scoped guard denies later calls. After commit, a scoped turn-stop ends the child turn after ordinary continuation has settled. A child that finishes without a committed capture returns an error; the provider does not re-prompt it.
## Correctness enforcement
Scope mistakes are fail-open if they merely omit a carrier, so the implementation checks the contract at API, type, runtime, and repository-gate boundaries. None of these checks substitutes for using the correct runtime carrier.
Scope selection would otherwise fail open to global-only behavior, so the contract is checked at several boundaries:
### API shape couples subjects that must agree
| Boundary | Check |
|---|---|
| API | Helpers couple the payload subject to the dispatch carrier; stores capture subjects they must use later. |
| Type system | Scoped event declarations require `Scoped<T>` receivers. |
| Runtime | Development invariants require marked carriers and compare keys with exposed subjects. |
| Repository gates | `verify-scoped-dispatch` aligns declarations with the invariant table; generated catalogs require recognized dispatchers. |
`agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling.
### Type markers cover every scoped event declaration
Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped<T>` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent.
The marker is compile-time only. JavaScript callers, casts, and direct use of Cordis's dispatch APIs can bypass it, which is why the runtime checks remain necessary.
### Development invariants check actual dispatch
The invariants plugin observes Cordis's internal dispatch path before listener delivery. For each scope-filtered event it requires a marked carrier and, where the event arguments expose the subject, verifies that the carrier key is the same object.
Session and subagent payloads do not expose the owner key directly, so their invariant proves carrier presence while their service centralizes how the correct key is chosen. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`.
### Repository gates keep declarations and dispatchers aligned
`verify-scoped-dispatch` compares the declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to have a recognized dispatcher. Source JSDoc is regenerated into the [event catalog](../../../cordis-catalog/events.md), keeping the exhaustive signature and mode reference in one place.
These checks make omissions visible but do not replace the runtime carrier.
## Alternatives considered
The rejected designs either split visibility from ownership, isolate the wrong boundary, or depend on extension ordering for correctness.
### Pass an agent option to every registration
An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and requires parallel scope plumbing in every registry. It also allows “visible to agent A, disposed with unrelated plugin B,” which the scoped context makes unrepresentable.
### Create one isolated service graph per agent
Service isolation chooses one registry instance for a context, while agent composition needs a merged view of deployment-global contributions plus one agent's additions. Per-agent graphs would duplicate shared adapters and force infrastructure such as persistence and UI bridges to discover every new instance.
Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment.
### Inherit the parent's scope into a child
Hierarchical capability inheritance makes lifetime convenient but silently grants every child the parent's scoped tools and policies. A flat view plus an explicit parent-owned disposer separates the two questions: the parent owns the child without conferring its authority.
### Publish the agent before running setup
Early publication lets setup resolve the agent from global registries, but observers can see and act on a partially configured world. Rollback can remove entries but cannot retract external effects from already-run listeners.
The unpublished callback already receives both the agent context and its `ctx.agent` association, so early global lookup is unnecessary.
### Allow only synchronous setup
Synchronous setup is simpler but cannot honestly compose a child plugin whose activation is asynchronous. In TypeScript, a callback returning a promise can also be assigned to a void-returning callback type, so declaring setup as synchronous would not reliably prevent accidental escape from the rollback boundary.
Awaited setup makes the transaction explicit and keeps the first assembly behind it.
### Enforce invariants with prepended waterfall listeners
A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool authorization, result commit, and turn continuation.
The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded.
### Filter events while keeping registries global
Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation.
### Add scope semantics to vendored Cordis
Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering. The harness-level primitive combines those mechanisms without adding a framework fork whose synchronization cost would outlive this feature.
| Alternative | Why rejected |
|---|---|
| Add `{ agent }` to every registration | Separates visibility from effect ownership and repeats scope plumbing in every service. |
| Build a service graph per agent | Duplicates shared infrastructure and cannot naturally merge global contributions with one agent layer. |
| Inherit the parent's scope | Couples ownership to authority and silently grants parent-scoped capabilities. |
| Publish before setup | Exposes partially configured agents; rollback cannot retract observer side effects. |
| Require synchronous setup | Cannot compose asynchronous plugins and is not reliably enforced by TypeScript callback assignability. |
| Prepend invariant listeners | Later prepends, short-circuits, and outer wrappers can still bypass or replace their results. |
| Scope only event delivery | Leaves schemas, lookup, prompt state, Code Mode bindings, and lifetime global. |
| Modify vendored Cordis | Existing contexts, fibers, and receiver filtering are sufficient; a framework fork adds unnecessary maintenance. |
## Consequences
The design makes per-agent composition ordinary and lifecycle-safe at the cost of a small scope runtime and several deliberately narrow final-policy APIs. The complexity is concentrated in services and dispatch helpers rather than repeated in every plugin.
Plugin authors use the same registration APIs globally and per agent; only the context changes. Prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from one agent view. Existing unscoped plugins remain deployment-wide contributors.
### Benefits
The implementation pays for per-scope maps, a proxy-shaped event carrier, explicit subject parameters on reads, and disciplined dispatch helpers. `agent.ctx` is capability-bearing and exposes the service surface injected into the agent loop. Flat scopes require child capabilities to be global or explicitly registered for the child.
The main benefit is one composition model across data, behavior, and lifetime: registrations follow their context, while service-owned finalizers protect only the invariants that require stronger ordering.
Reserved transport and final-policy APIs are deliberately narrow. `run_code` cannot be removed by an end-capability filter; policy that forbids programs must deny execution. Prompt protection preserves named canonical contributions, not the whole assembly. Terminal turn stopping may discard steering and is too strong for ordinary cooperative policy.
- Plugin authors use the same registration APIs globally and per agent; only the context changes.
- Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view.
- Create and resume expose no partially configured registry entry during awaited setup.
- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled.
- Structured output composes per child without global mutation or listener-order assumptions.
- Existing unscoped plugins remain deployment-wide contributors and observers.
### Costs and constraints
The costs are concentrated in dispatch discipline, per-scope registry state, and explicit authority boundaries that are intentionally stronger than ordinary middleware.
- Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners.
- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface.
- Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime.
- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject.
- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child.
- `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt.
- Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing.
- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy.
- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The config-only `ctx.agentLoop.create()` path has no setup callback and remains synchronous.
- Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements.
### Deliberate boundaries
The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and future registries retain their existing seams until their own designs explicitly adopt the context rule.
This decision applies scoping to tools, prompt state, selected live events, sessions, and in-process subagent composition. It does not make every service call agent-scoped; other capabilities adopt the context rule only through their own explicit contracts.
@@ -76,7 +76,7 @@ Per explicit-over-implicit at seams, the request spells out everything the runti
### Trust posture
The worker runtime is **containment, not a security boundary**. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, and a separate isolate. A `node:vm` executor with no containment would need explicit unsafe acknowledgement; imposing that ceremony on the better-contained worker while bash needs none would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor lets deployments distinguish backends.
The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. Code Mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend.
### What the model sees
@@ -54,7 +54,7 @@ This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; com
Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed.
So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained node is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over the surface linked list, **not** the log's `step/*` markers: a compaction lands a replacement node at a high log seq whose surface position is the head, so a log-position scan mis-reads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step.
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
@@ -37,7 +37,7 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de
### Adding context is not a veto — delegate, then fold
A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. So on the context-only path each bridge **delegates via `next()`** and then **folds** its `additionalContext` onto the downstream decision (`concatContext`). The fold differs by seam because the two Decision unions differ: `tools/post-execute` — a downstream `block`/`accept` both carry an `additionalContext` field, so the bridge context rides along either way (a downstream block wins AND keeps the context; a downstream accept keeps its content rewrite and gains the context). `agent/prompt-submit` — a downstream `allow` gains the bridge context (and keeps its own content rewrite / additionalContext), but `PromptDecision.block` carries no context field, so a downstream block drops the bridge context — which is correct: a blocked prompt never reaches the model, so context attached to it is moot. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed, and that both contexts survive when the downstream also adds one.
A context-only hook must call `next()` and then fold its `additionalContext` into the downstream decision; returning allow or accept directly would bypass later policy listeners. Post-tool block and accept decisions both preserve added context. Prompt allow preserves it, while prompt block drops it because the prompt never reaches the model. Only an explicit hook denial or block short-circuits the waterfall.
### CLAUDE_PROJECT_DIR defaults to the session workspace
@@ -24,11 +24,11 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here.
**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta shape-validation and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate.
Each workflow run gets one worker thread. A vm inside the worker limits script-visible globals while message-port RPC keeps child agents on the host loop. Host-side parsing preserves synchronous start errors; a ready/go handshake prevents pre-start cancellation from running code; host cancellation and child tracking handle wedged workers; the grace period ends with `worker.terminate()`. The private wire protocol uses typed payload maps. Tests exercise the worker session through `MessageChannel` and the built worker under plain Node. `isolated-vm` was rejected because its runtime and build requirements would burden every consumer.
**Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys.
**Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
`materializeFromRealm` copies JSON-compatible values out of the script realm and rejects exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, and nested `undefined`; data properties are defined safely so `__proto__` cannot mutate prototypes. Inputs are cloned before script access. Engine-generated `WorkflowError`s remain distinguishable by name and code, while a total renderer converts arbitrary thrown script values into a non-rejecting result. Stage functions stay inside the realm. Concurrency, item, total-agent, and timeout limits are validated configuration.
### The consumer (dsh-tool-workflow)
@@ -49,7 +49,7 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
#### The seam: mechanism and policy split
`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome``allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. A session observer runs after an event enters the append-only log; if one throws, the service recognizes the recorded event, contains the callback failure, and completes the pair. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. The one precondition: `request()` throws (before appending anything) when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
`ApprovalService.request` snapshots and shallow-freezes the request, then resolves to the closed `ApprovalOutcome` vocabulary without rejecting. It races the captured signal, maps abort to `cancelled`, contains throwing or invalid answerers as `unavailable`, and writes the paired `approval/asked` and `approval/decided` events using a branded request id. Observer failures are contained after the event is logged, so the pair still completes. Grants are one-shot and stored nowhere. Requests require an open turn because audit events must remain inside the durable turn boundary.
Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates.
@@ -71,7 +71,7 @@ Left open, for the phase that needs them: whether network restriction arrives as
#### Local backends and the shipped launcher
`dsh-sandbox-local` selects BY PLATFORM, once per lifetime, and caches the verdict: each platform names its runner chain, a chain of one is selected directly — probing arbitrates between candidates, and a sole candidate leaves nothing to arbitrate — and a chain of several is probed FUNCTIONALLY in preference order (build and enforce a real profile, never `--version` — a present-but-unusable `bwrap` must fail its probe). Linux: `bwrap` first (its mount profile is closest to the mode vocabulary: whole tree read-only, fresh `/dev`+`/proc`, `workspace-write` adds an ephemeral `/tmp` and rebinds the workspace root; deliberately no `--unshare-pid` and no network claim), else the npm-distributed `landlock-run` Landlock launcher. darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile — allow-default with `(deny file-write*)` plus write allow-lists, every granted root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`) — unprobed, the sole candidate. A platform with no chain fails closed at `confine()`; an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap carries `runnerFailureSignatures` (the runner's own error prefix, which also matches the shell's runner-not-found message) so the consumer classifies that as a SANDBOX failure, never a task failure: on either path the command neither runs unconfined nor slips through as a plain failure. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile — chain and probes skipped; it doubles as the deterministic fake-runner seam for keyless tests. It is not exempt from fail-closed execution: its wrap carries argv0-scoped outer-shell failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) as its runner-failure dialect, so a missing or unexecutable configured runner classifies as a sandbox failure like every other rung — never as a failing command, and never as a denial.
`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined.
The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro <path>` / `--rw <path>` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing.
@@ -83,7 +83,7 @@ Profile parity is honest rather than identical: under Landlock, `read-only` gran
#### The bash consumer
`dsh-bash-sandbox` extends `LocalBashExecutor` (spawn mechanics, process-group kills, spill files, background tasks, credential scrub inherited verbatim) and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A sandbox denial is a RESULT FACT, not an error: the command RAN and the kernel refused a file operation, so `result.sandbox.denied` is orthogonal to `exitCode`/`signal`. Classification is conservative text inference over the collected stderr tail against the WRAP's own dialect, so a backend is never credited with a denial text its kernel does not speak (bare EPERM under a Linux runner names non-file boundaries the mode vocabulary does not govern); the known residual false positive is non-sandbox text in the active dialect (an ssh auth failure under Landlock, a refused `kill` under Seatbelt), and a structured runner signal wins once one exists. A RUNNER failure is the opposite of a denial and outranks it in classification (a runner's error text can itself contain denial words): the wrap's `runnerFailureSignatures` matching a failed run means the sandbox broke and the command NEVER RAN — the foreground path re-throws it as the structured `SANDBOX_UNAVAILABLE` error (the late twin of the confine-time throw, carrying the runner's first stderr line), a settled background task stamps `sandbox.runnerFailed` and `bash_output` renders its own marker — so a broken sandbox can never read as a failing command.
`dsh-bash-sandbox` reuses local process execution and asks `ctx.sandbox` to wrap the exact bash argv. A kernel denial is a result fact independent of exit status and is inferred only from the selected wrap's stderr dialect. Runner failure outranks denial because it means the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background tasks set `sandbox.runnerFailed` for `bash_output`. This keeps broken confinement distinct from both task failure and an enforced denial.
The model's view is result facts only: the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes).
@@ -93,9 +93,9 @@ The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: Sandb
`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. The `danger-full-access` branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (`notifyTaskDone()` stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own.
The tool gate advertises two extra parameters exactly when `ctx.bash.sandboxMode` reports a confining mode at registration: `sandbox_permissions`, an enum of the closed escalation-target vocabulary — `workspace-write`/`danger-full-access`, every mode a session could ever escalate TO — and `justification`, required together with it. The enum is deliberately NOT cut down to the modes wider than the executor's DEFAULT: schemas are registry-global while the effective mode is per-session and switchable, so a default-relative ladder strands a session overridden NARROWER than the default (with a `danger-full-access` default and a `read-only` override it would advertise nothing at all — confined, but with no lever). Strict widening is instead enforced at EXECUTION against the call's effective mode (session override ?? executor default): a request that is not strictly wider fails closed with its own text and prompts no one. An escalating call resolves approval BEFORE anything executes — no `ctx.approval` composed, or no agent on the execution, fails closed with its own text; otherwise `ctx.approval.request({ agent, toolName: 'bash', callId, reason, signal })` with the audit-self-contained reason `escalate sandbox to ${mode}: ${justification}`, while the UI attaches the prompt to the already-streamed call (the command is visible there; the approval RFC's no-arguments rule holds). The four outcomes map to distinct results: `allowed-once` stamps `sandboxMode` onto the bash request and proceeds; `rejected`, `cancelled`, and `unavailable` each produce their own error text, so the model can tell a human "no" from a dismissed prompt from a missing channel. The grant is consumed by the very call that asked; nothing is stored.
When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
The tool description teaches — and a denied result itself prompts — the SAME-TURN flow when the fields exist: on a denial a wider mode would cure, escalate immediately in that turn by retrying the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification`, without detouring through chat to ask first — the approval prompt raised by the retry IS how the user consents. Never speculatively: an escalation is grounded in a real denial — normally the one the command just hit, up front only when the session already denied the same access and a prompt stating approvals are disabled turns the exception off entirely; a rejected escalation is final for that command. Denial-grounding is deliberately model discipline plus human judgment, not harness bookkeeping — the human sees the exact command and justification on the prompt (see Alternatives for why hard-matching is rejected). No new session events anywhere: the attempt is an ordinary `tool/call` whose logged arguments carry the two fields, the decision is the approval seam's `approval/asked`/`approval/decided` pair, the outcome is an ordinary `tool/result` whose sandbox facts name the mode it ran under. The asker lives in `dsh-tool-bash`, NOT the executor: a transport seam has no `agent`, no `callId`, and no business asking humans questions.
Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor seam has neither the agent nor call id required for user interaction.
Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; how cancellation behaves while an approval prompt is pending; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`.
@@ -118,7 +118,7 @@ interface SessionEventMap {
Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval RFC](2026-07-06-approval-seam.md)'s side of the same pattern.
**Visibility is deliberately asymmetric between the knobs.** The SANDBOX mode is stated nowhere and its switches are not narrated: a standing "you are read-only" declaration teaches the model to refuse preemptively (observed live: sessions where the model would not even attempt a write it could have escalated), while the denial marker already names the mode the command ran under at exactly the moment the boundary matters — behavior, not belief, carries the state, and a switch simply changes what the next command does. The APPROVAL policy keeps both layers, because its failure mode is the opposite: an auto-rejected ask under `'never'` returns "the user rejected …" wording no behavior can disambiguate, so the prompt states `'never'` (and ONLY `'never'` — an `'ask'` promise is unknowable without asking, and absence under a logged header is how the narrator reads `'ask'` back), and an `agent/pre-step` narrator injects at most one coalesced notice per policy switch: idle flip-flops collapse to one notice at the next turn's first step, a net-zero round trip to none, and a mid-turn change is narrated no later than the next step. Its "last told" is in-memory with a log-derived fallback (the folded header's system text parsed against the closed candidate sentence; LAST occurrence wins, so a persona quoting it cannot shadow the real section), so restarts lose nothing; attribution is positional (a knob event after the log's last `request/header*` reads `changed by the user`, a drift with no such event reads `changed by the operator/config`).
Sandbox mode is not narrated in the prompt; denial results report the mode when it matters, avoiding preemptive refusal based on a standing label. Approval policy is different: only `'never'` is stated because automatic rejection otherwise looks like a user decision. Policy-change notices are coalesced and delivered by the next pre-step, with log-derived fallback after restart. The notice source is inferred from event position: a knob event after the last request header is user-driven; unlogged drift is operator or config driven.
**The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. The bridge advertises one independent `select` per composable knob — `sandbox-mode` (category `mode`) iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with `currentValue` folded from each session's own log, in `session/new` and `session/load` responses. `session/set_config_option` validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract).
@@ -30,7 +30,7 @@ Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
Three boundary mechanisms make model-written code behave correctly across the realm seam. **Dual-realm `instanceof`**: most objects sandbox code touches are host-realm (tool `args`, event payloads, service returns), so a plain `x instanceof Array` in the vm would silently be false — a per-sandbox prelude gives the vm realm's own constructors a `Symbol.hasInstance` that checks both the vm constructor and its host counterpart, patching only vm-realm globals. **Realm normalization of tool results**: objects built inside the vm carry the vm realm's `Object.prototype`, which the session log's append-time plainness check (`isJsonValue` in `dsh-session`, a prototype-identity comparison) rejects, so the sandbox's `harness.defineTool` JSON round-trips every `execute` return into the host realm — which also projects it onto exactly what the log durably stores — and then shape-checks it against the two `ToolExecuteReturn` forms, so a JSON-valid but wrong-shape return (a bare string, `{ content: 'ok' }`) fails that one call with a teaching error instead of entering the log as corrupt tool-result content. **A whitelist context façade**: the `ctx` a mounted plugin's `apply` receives is NOT the real context nor a pass-through proxy over it — it is a façade exposing only what a mount legitimately needs (`tools.register` marker-guarded, a read-only `tools.get`/`schemas`, `on`/`once`, `provide`, the timer helpers, and the services the plugin DECLARED in `inject`), with every framework-plumbing member (`root`, `parent`, `fiber`, `reflect`, `registry`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) denied with a teaching error. This closes an escape *class* rather than a single hole: a proxy that merely special-cased `ctx.tools` still handed back the raw context through `ctx.root`, `ctx.extend()`, or a service instance's `.ctx`, and mount code could then `ctx.root.tools.register({…})` to bypass the marker check and realm normalization — a raw vm-realm result then errors a real agent turn at the plainness check. The façade has no context-valued member to reach, and the one indirect leak (an injected-service method returning a `Context`) is rejected on the way back to sandbox code. Two narrower rules complete the surface. First, **service access requires an `inject` declaration**: reaching a service the mount did not declare is refused even when a global provider is live — otherwise a mount could depend on a provider cordis never sees, and unmounting that provider would neither park the consumer nor unwind the tools it registered, leaving a model-visible tool that fails only at execution time. Because the read is gated on the declaration, cross-mount `provide`/`inject` keeps its lifecycle guarantees (the plugin's own `inject` and the fiber's pending/active gating drive activation and unload); only the `apply`-time `ctx` surface is narrowed. Second, **`ctx.tools.get` returns a read-only schema view** (name/description/parameters), never the live `ToolDefinition` — handing back the definition would expose its `execute`, letting mount code call another tool directly and bypass `ToolRegistry.execute` and its pre/post-execute hooks and accounting; a mount that wants to invoke a tool must go through the registry, and one that wants to introspect gets the same view `schemas()` returns.
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly.
Boundary errors are written around the mistakes models actually make (see [Consequences](#consequences) for how each was found), and the boundary normalizes rather than lectures wherever the input has exactly one meaning: schema `parameters` accept the JSON-Schema dialect models write by strong prior — the `{ type: 'object', properties, required: […] }` wrapper unwraps to the SchemaSpec DSL (the `required` array becoming per-property flags, at any nesting level), `type: 'integer'` maps to `number`, and `required: false` reads as optional — while genuinely meaningless input is rejected with the vocabulary enumerated (an unknown type lists the five valid ones; a non-boolean `required` names the rule). The remaining teaching errors: an unbalanced `});` closing gets the vm's offending source line plus a "code is a function body" reminder; TypeScript syntax gets the remove-annotations fix (detected on the failing line only, so an ` as ` inside a description string does not misfire); a forgotten `return` gets the two valid plugin forms; a Node built-in call gets the redirect to its cordis service; a tool-name collision on re-mount gets the unmount-first-then-remount recipe.
@@ -4,7 +4,7 @@ Status: implemented
## Problem
The session event log is the harness's on-disk contract: every `SessionEventMap` member is a record a persistence backend writes verbatim and a replay reconstructs from, and adding one that breaks the durability rules is a breaking change to the on-disk format. Yet the vocabulary had no single reference. The declarations are split across three files — the owning interface in `@deepseek-ai/dsh-session` plus declaration merges in `@deepseek-ai/dsh-compact` and `@deepseek-ai/dsh-hook-protocol` — and the doc surfaces covered it with hand-copies: a `hook/*` payload table in [session.md](../../../core-data-structures/session.md), a `compact/*` payload table in the compact README, payload bullets in the hook-protocol README, and a name-list in the session README. The name-list's merge note had already drifted (it named the compaction merge and omitted the hook merge entirely), and nothing could catch the next merge going undocumented: a hand-copy only checks the names someone already wrote down. This is the same gap the [cordis catalog](2026-06-20-generated-cordis-catalog.md) closed for bus events and the [tool catalog](2026-07-02-tool-schema-catalog.md) closed for model-facing tools — and log events are covered by neither: a `SessionEventMap` member is not a cordis `Events` declaration (it reaches listeners via the single `session/event` emit), so it has no cordis-catalog row by design.
`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event and payload; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output.
## Decision
@@ -13,7 +13,7 @@ Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the
## Decision
`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`).
`HookDialect` is the closed bridge set, `'claude' | 'codex'`; `HookOutput` omits unsupported `suppressOutput`. `hook/result.durationMs` remains durable audit timing and is normalized only in snapshots. Reference defaults live once in `DEFAULT_HOOK_TIMEOUT_MS` and `DEFAULT_STDERR_SUMMARY_MAX_CHARS`. `HookResultRecord` and `appendHookResult` own stderr summarization and decision derivation for both bridges. `BLOCKING_EXIT_CODE` is codec-internal.
## Alternatives considered
@@ -68,7 +68,7 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack
### Two subcommands, replay in the default gate
`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` + `session.jsonl` for ALL scenarios — the harness passes `<dir>/session.jsonl` to `llm-replay` unconditionally, so even a no-model scenario needs its header-only fixture or `loadReplayScript()` fails; `replay.override.json` exactly for the scenarios whose table entry sets `overridden` — required with the flag, forbidden without it, because the harness forwards the sidecar purely on file existence and an unregistered stray would silently replace the derived script).
`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout golden. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.golden.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters.
## Alternatives considered
@@ -24,7 +24,7 @@ The runtime should own:
## Current seam consumption
A consumer census of the surface the runtime would carve up. Production has two seam consumers: `packages/bash/tool-bash/src/index.ts` consumes `resolve`, `run`, `start`, `ownerOf`, `readOutput`, `kill`, and `onTaskDone`; and the hook bridges — via `dsh-hook-protocol`'s `runHook` (`packages/hooks/hook-protocol/src/runner.ts`) — consume `resolve` + `run` only, a foreground-only trusted-plugin caller that sets the seam's `stdin`/`env` fields, so the background machinery stays single-consumer (which sharpens the extraction premise). `get()`/`list()` have test-harness consumers only — they were removed once and reverted on the merits (the implementation note in [prune dead methods from the persistence seam](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records the test-migration cost dwarfing the surface removed). The per-task `BashTask.done` promise has no consumer through the public seam either (`dsh-tool-bash` completes via `onTaskDone`), but it is production-load-bearing INSIDE the implementation: `dsh-bash-local`'s disposal awaits it to reach quiescence. The seam therefore exposes two public completion representations — the per-task promise and the global `onTaskDone` listener registry — and the shipped consumers use only the latter: the runtime should pick exactly one public completion surface and record which. Two shape facts for the split to dissolve or preserve deliberately: `BashExecSpec.timeoutMs` is required but ignored by `start()` (documented in the seam JSDoc itself), and `stdin`/`env` ride the shared spec for the foreground trusted-plugin path — the carve-up must keep a plain in-process foreground `resolve`+`run` path carrying them, so hook execution is never forced through the long-running runtime. Adjacent blast radius: the credential scrub is duplicated between the two production spawn sites (`packages/bash/bash-local/src/run.ts` and `packages/subagent/subagent-acp/src/run.ts`); if the runtime absorbs spawn-env policy, collapsing that duplication is its work too.
Current consumers split cleanly: `dsh-tool-bash` uses the full foreground/background seam, while hook bridges use only foreground `resolve` and `run` with trusted `stdin` and `env`. `get` and `list` are test-only; `BashTask.done` is implementation-only for disposal, while production completion uses `onTaskDone`. An extracted runtime should expose one public completion mechanism, preserve the simple foreground path for hooks, and decide whether background `timeoutMs` belongs on `start`. If it owns process spawning, it should also centralize the duplicated credential scrub.
## Acceptance criteria
+2 -20
View File
@@ -2,19 +2,7 @@ import stylistic from '@stylistic/eslint-plugin'
import tseslint from 'typescript-eslint'
/**
* ESLint flat config. Two layers:
*
* 1. typescript-eslint strict-type-checked — correctness rules that need the
* type checker. The headline rules for this codebase: no-floating-promises
* and no-misused-promises (an un-awaited promise in the agent loop is our
* primary bug class), switch-exhaustiveness-check (we switch over
* merge-extensible unions everywhere).
* 2. @stylistic — formatting (2-space, no semicolons, single quotes, trailing
* commas), so style is enforced rather than drifting between agents.
*
* vendor/ is linted lightly (style only stays OFF — vendored code keeps
* upstream style; only a few safety rules apply there) and examples/tests are
* linted with relaxed unsafe-* rules where mocks intentionally bend types.
* ESLint flat config. Two layers.
*/
export default tseslint.config(
{
@@ -39,13 +27,7 @@ export default tseslint.config(
],
languageOptions: {
parserOptions: {
// One shared tsserver-style project service instead of 60+ standalone
// per-package programs: the old `project` glob built every package's
// full dependency closure (sibling sources via the dev `paths` map +
// the vendored Cordis stack) as its own program and kept them all
// resident — ~5 GB peak, an OOM past node's default heap. The service
// resolves each file to its nearest owning tsconfig and shares the
// graph.
// Share one project service to avoid per-package graphs and excessive memory.
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
+7 -7
View File
@@ -1,19 +1,19 @@
# AGENTS.md — Examples
Runnable demos showing how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub, never built. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`.
Runnable harness compositions. **Examples are not workspaces:** their private package stubs are not built; `tsx` and the Cordis Loader resolve package names through the root `tsconfig.json` paths.
Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`.
Keep only wiring, demo-only fixtures, and e2e/snapshot scenarios here. Move reusable logic into `packages/`, where coverage and README requirements apply. App-package bins own bootstrapping; examples have no `start.ts`.
## Every example ships e2e smokes (keyless + with-key)
Each example must have **both** kinds of end-to-end smoke, because they catch different failures:
Each example has both smoke tiers:
- **Keyless smoke** boot the example through its real `cordis.yml` via the Loader (no API key), drive it, and assert the rendered output and a clean exit. This is the guard a hand-mounted unit test structurally cannot be: it exercises the REAL load path (`unwrapExports`, `inject`, the whole plugin tree), so a broken plugin export shape — e.g. a stray `export default` that collapses a namespace plugin and drops `inject` — fails here even when unit tests stay green (see [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md)). It runs in the default e2e gate (CI has no secrets).
- **With-key smoke** — send a real prompt against the live model and verify the WORLD (a file on disk, a non-empty assistant turn), not the agent's self-report. This proves the actual product works, which a mock/keyless run structurally cannot. Key-gated: it self-skips without `DEEPSEEK_API_KEY` (see [the testing policy](../docs/testing.md) — inference is cheap here, so write many).
- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output plus clean exit. This catches Loader/export-shape failures that hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md).
**Exception — keyless-by-nature examples.** An example whose model is itself a mock/deterministic stand-in (no real provider) has no meaningful with-key smoke; the keyless smoke is the complete requirement. State the exception inline in the test.
Mock-only examples need only the keyless tier; state the exception in the test.
A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_PATH` to the repo-root tsconfig (the unbuilt `paths` map is found by searching UP from cwd), and pass `--expose-internals` when the `cordis.yml` loads the HMR plugin (mirror the `demo:*` script).
A keyless smoke launched from a temporary cwd sets `TSX_TSCONFIG_PATH` to the root tsconfig and passes `--expose-internals` when loading HMR.
## Current state
+1 -1
View File
@@ -33,7 +33,7 @@ The editor sets each session's `cwd` to the project it opens; both the agent's b
## Snapshot tests (record-once / replay-deterministic)
This example is the home of the harness's **snapshot tests** — they boot this server as a real subprocess, drive it with a deterministic input script, and diff its normalized output against committed golden files. The model is made deterministic by `@deepseek-ai/dsh-llm-replay`, a function/namespace plugin that installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded **session JSONL** fixture (`<scenario>/session.jsonl`) — so replay needs no API key. The fixture IS the persisted session log: its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`"; use `pnpm run test:snapshot:record` when the model transcript itself should change, and `pnpm run test:snapshot:refresh` when the committed model transcript is still the right mock input and only the current replay output/goldens need to be rewritten. The two failure modes not expressible as logged chunks — a pure throw before any chunk, and cancel/hang — use an optional `<scenario>/replay.override.json` sidecar (a `ReplayEntry[]` that replaces the derived script). A scenario that needs the agent to operate on existing files ships an optional `<scenario>/workspace/` directory — the harness copies its contents into the temp cwd before the run (see `workspace-edit`). See [the ACP snapshot tests RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) for the full design.
This example hosts the ACP snapshot suite. `dsh-llm-replay` reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL, so replay is keyless. Recording runs the real agent and harvests that log; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the full design.
## MVP limitations
+10 -38
View File
@@ -26,27 +26,12 @@ import {
* WITHOUT a key, since it only needs the server to boot and answer initialize.
*/
// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The
// bin resolves its config-path arg from CWD; the subprocess runs from a temp
// workdir, so pass the example config's ABSOLUTE path.
// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml.
const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to
// a temp workdir (this test launches there and uses it as the session cwd; the
// bridge no longer requires cwd === the launch dir, but a temp dir keeps the
// test hermetic), where a bare `--import tsx` would not resolve from
// node_modules. import.meta.resolve gives the worktree's tsx regardless of cwd.
// Resolve tsx absolutely because the subprocess runs outside the repo.
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Absolute path to the repo-root tsconfig. Dev/test/demo run UNBUILT: the
// `@deepseek-ai/dsh-*` workspace imports resolve through the `paths` map in the
// root tsconfig (tsx reads it), NOT through built `lib/` output. But tsx finds
// that tsconfig by searching UP from the child's cwd — and the child's cwd is a
// temp workdir OUTSIDE the repo, so the search misses and the dsh-* imports fail
// (the child dies before writing a byte). Point tsx at the repo tsconfig
// explicitly via TSX_TSCONFIG_PATH so resolution is cwd-independent. (Without
// this the suite only passed by accident when a stale built `lib/` happened to
// exist — exactly the contamination that masked the inject bug this suite now
// guards.) The repo root is four levels up from this file (examples/acp-agent/tests).
// Absolute path to the repo-root tsconfig.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
interface Spawned {
@@ -155,9 +140,6 @@ describe('acp-agent over real stdio (no key required)', () => {
it('emits only framed JSON-RPC on stdout', async () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
// Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
// A dummy key lets the deepseek adapter APPLY (it only checks the key is
// present at boot, not valid — the key is used only on a real model call,
// which this purity test never triggers). So this runs WITHOUT real creds.
const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
cwd: workdir,
env: {
@@ -196,17 +178,10 @@ describe('acp-agent over real stdio (no key required)', () => {
}, 30_000)
it('session/new succeeds over real stdio (no model call)', async () => {
// REGRESSION GUARD (this exact RPC crashed a real Zed session with
// "cannot get property \"agents\" without inject"): `session/new` drives the
// full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop →
// registry/persistence path, ALL of which run from the JSON-RPC read loop
// OUTSIDE the bridge plugin's injection scope. A lazy `ctx.<service>` read
// on that path throws and the RPC fails with an Internal error — yet the
// call never touches the model, so this reproduces WITHOUT a key. The
// key-gated prompt test below never caught it (it needs real creds); the
// initialize-only purity test never caught it (initialize does not reach
// the factory). This closes that gap: boot the real subprocess and create a
// session, asserting the RPC RESOLVES (not rejects with an inject error).
// Regression guard (this exact RPC crashed a real Zed session with "cannot get property
// \"agents\" without inject"): `session/new` drives the full bridge →
// `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → registry/persistence path, ALL
// of which run from the JSON-RPC read loop outside the bridge plugin's injection scope.
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
// A dummy key lets the deepseek adapter boot (it only checks presence, not
// validity, at apply time); no model call is made, so the key is never used.
@@ -245,12 +220,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
expect(toolCalls.length).toBeGreaterThan(0)
// Tool-call UI quality (the tool owns its presentation): the bash tool's
// `presentCall` sets the title to the exact command (an execute card hides
// rawInput, so the command IS the title) — NOT the bare tool name "bash".
// A `bash` call must therefore carry an execute kind, a non-"bash" title,
// and a string rawInput (the command). `toolCalls` is already narrowed to
// the `tool_call` shape by the filter above, so these fields are reachable.
// Tool-call UI quality (the tool owns its presentation): the bash tool's `presentCall` sets
// the title to the exact command (an execute card hides rawInput, so the command IS the
// title) — not the bare tool name "bash".
const bashCall = toolCalls.find(u => u.kind === 'execute')
expect(bashCall).toBeDefined()
if (bashCall === undefined) throw new Error('expected an execute tool_call')
+7 -28
View File
@@ -75,31 +75,12 @@ const SCENARIOS: Scenario[] = [
// child runs as a spawn subagent under the worker-thread engine (its session is the
// child fixture), and the tool result carries the script's return value.
{ name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 },
// Hook matrix — one scenario per hook point × its headline Decision outcome,
// across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in
// workspace/). The block scenarios need no model call: a UserPromptSubmit hook
// blocks the prompt before any step runs (keyless, authored — the derived
// script is empty so no sidecar), yet persists a `rejected` turn carrying
// `hook/*` events, so their logs ARE compared. Every other point fires a real
// seam mid-turn, so its transcript is recorded WITH the hook active.
// Hook matrix — one scenario per hook point × its headline Decision outcome, across BOTH
// bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in workspace/).
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false },
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false },
// The mid-turn seams fire during a real model turn, so each is recorded WITH
// its hook active (the model's reaction to a deny/block/force-continue is part
// of the captured transcript). The Codex bridge exercises the same seams in its
// own snake_case dialect.
//
// Two hook points are deliberately NOT snapshotted, and stay on the bridges'
// unit coverage (`bridge.spec.ts` / `coverage.spec.ts`) instead:
// - SessionStart and SubagentStart inject context through a detached,
// best-effort `void runPoint(...).then(agent.inject())` with no turn
// binding, so the resulting `context/message` races the work it precedes
// and lands at a nondeterministic log position — a recorded golden does not
// even reproduce on its own replay.
// - SubagentStop is observe-only with no turn and no injection, so it writes
// NOTHING to the transcript — a golden would be byte-identical to the
// no-hook run and could never be proven to fail.
// See the hook-snapshot-matrix RFC for the full rationale.
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
{ name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
@@ -114,11 +95,9 @@ const SCENARIOS: Scenario[] = [
{ name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
// Code Mode: the registry in `mode: code` — the wire tool list collapses to
// [run_code], the tools:sdk section rides in the prompt, and the program's
// tool calls land as tool/code-dispatch events. Each mode boots its own
// overlay config, composes a different header by construction, and
// therefore pins its own class.
// Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
// tools:sdk section rides in the prompt, and the program's tool calls land as
// tool/code-dispatch events.
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
{ name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG },
]
+3 -18
View File
@@ -17,21 +17,8 @@ import {
} from '@agentclientprotocol/sdk'
/**
* With-key e2e: the Claude Code hook bridge running against the REAL acp-agent
* subprocess and the REAL model. The example `cordis.yml` loads `dsh-hooks-claude`
* with a PROCESS-LEVEL `configPath` of `./hooks.json`, resolved once at load
* against the ACP server's launch cwd (NOT per-session); this test sets that
* launch cwd to the temp workspace and writes a `hooks.json` there with a
* PreToolUse hook that BLOCKS every bash command, then asks the live model to
* write a file — and verifies the WORLD (the file never appears on disk),
* proving the hook actually intercepted execution rather than the agent merely
* claiming it couldn't. (The hook itself then runs in the session cwd.)
* Key-gated; owns and disposes its subprocess.
*
* A keyless companion lives in acp.e2e.ts (stdout purity + session/new); the
* full hook-fires-end-to-end transcript is the keyless `hook-cc-promptsubmit-block`
* snapshot scenario. This one closes the "green plumbing, broken product" gap:
* only a real model deciding to call bash exercises the PreToolUse seam live.
* With-key e2e: the Claude Code hook bridge running against the real acp-agent subprocess and
* the real model.
*/
const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
@@ -89,9 +76,7 @@ afterEach(async () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
it('denies every bash command, so the requested file is never written (verified on disk)', async () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-hooks-e2e-'))
// A PreToolUse hook that blocks EVERY tool (exit 2, no matcher = match-all).
// The session cwd is `workdir`, and the bridge resolves `./hooks.json` from
// the process cwd (the launch dir = workdir), so this is the config it loads.
// A PreToolUse hook that blocks every tool (exit 2, no matcher = match-all).
await writeFile(join(workdir, 'hooks.json'), JSON.stringify({
hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] },
}))
@@ -6,17 +6,11 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for the Code Mode overlay: boot the REAL
* example through the `@deepseek-ai/dsh-stdio-agent` bin against
* `code-mode.cordis.yml` (the cordis Loader, `unwrapExports`, the include
* patches over ./cordis.yml, the worker-thread code runtime, and the
* registry in `mode: code`), then close stdin with no prompt and assert
* the Code Mode banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called and no `run_code`
* turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot
* the tree. This is the export-shape guard (postmortem 0001) for the Code
* Mode composition; the with-key proof lives in `code-mode.e2e.ts`.
* Keyless Loader-path smoke for the Code Mode overlay: boot the real example through the
* `@deepseek-ai/dsh-stdio-agent` bin against `code-mode.cordis.yml` (the cordis Loader,
* `unwrapExports`, the include patches over ./cordis.yml, the worker-thread code runtime, and
* the registry in `mode: code`), then close stdin with no prompt and assert the Code Mode
* banner + a clean exit.
*/
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
@@ -26,10 +20,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
// startup can therefore outlive a tight smoke-test deadline before the child
// emits any output; 30s still detects a wedged process without confusing
// bounded CI contention with a lifecycle failure.
// The real-API workflow runs up to 14 e2e files at once.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
+3 -28
View File
@@ -6,26 +6,8 @@ import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
/**
* The compaction smoke test: a real model runs a multi-step bash task with a
* deliberately tiny context window, so the auto-compaction listener fires
* MID-SESSION and summarizes the older history into a checkpoint. This is the
* first end-to-end exercise of the compaction seam (it is wired nowhere else),
* and the runaway-survival regression net — it proves a session that grows past
* the window keeps running rather than overflowing. Key-gated.
*
* Verifies the WORLD, not the agent's self-report: a compact/start…end pair
* landed in the real session log, the surface actually shrank (a replace node
* exists and shadowed older nodes), and the agent still produced a final answer
* after compaction (so the summarized history did not break the conversation).
*
* FIXME(compaction-snapshot): this key-gated e2e is the ONLY coverage of runaway
* compaction — there is no keyless full-transcript snapshot of it. dsh-llm-replay
* reconstructs one model call per (turn, step) from `assistant/chunk` events, but
* `summarize()` assembles its stream into a local BlockAssembler and appends no
* `assistant/chunk`, so the interleaved summarization call is unreplayable. A
* snapshot needs replay-harness work to serve that call; deferred as a follow-up.
*/
/** Key-gated smoke for mid-session compaction and continued agent progress. */
// FIXME(compaction-snapshot): replay cannot serve the unlogged summarization model call.
let workdir: string | undefined
let ctx: Context | undefined
@@ -40,18 +22,11 @@ afterEach(async () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => {
it('summarizes older history into a checkpoint without breaking the task', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-'))
// A handful of files for the model to read, so multiple bash steps
// accumulate surface nodes (tool calls + results) and grow the history past
// the (deliberately tiny) window.
for (let i = 1; i <= 4; i++) {
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(50))
}
// Tiny window so a couple of steps crosses the threshold. The generation
// cap is deliberately larger than the final checkpoint because
// reasoning-capable APIs count reasoning tokens against the provider output
// budget even though those blocks are stripped before the checkpoint is
// stored.
// Reasoning tokens require a larger generation cap than the retained checkpoint.
ctx = await codingHarness(workdir, {
persona: SYSTEM_PROMPT,
compact: {
@@ -6,28 +6,14 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
* through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the
* cordis Loader, `unwrapExports`, the full plugin tree incl. the
* `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI
* module), then close stdin with no prompt and assert the
* ready banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called — this is why it runs
* without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose
* `apply()` only requires a key to be PRESENT (it does not validate it and only
* uses it when a stream actually starts), so a dummy key lets the tree boot
* while the absence of any prompt guarantees no network call. The value is the
* real-Loader-path guard that the composed tree boots (see postmortem 0001;
* the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent
* unit suite's unwrap assertion, not by a crash here),
* complementing coding-agent's with-key e2e suites which prove the real
* product.
* Keyless Loader-path smoke for examples/coding-agent: boot the real example through the
* `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the cordis Loader,
* `unwrapExports`, the full plugin tree incl. the `@deepseek-ai/dsh-agent-core` bundle and the
* app's in-package readline UI module), then close stdin with no prompt and assert the ready
* banner + a clean exit.
*/
// The dsh-stdio-agent bin (the demo:repl entry) and this example's cordis.yml.
// The bin resolves its config-path arg from CWD; the test spawns from a temp
// cwd, so we pass the example config's ABSOLUTE path.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
@@ -35,10 +21,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig (root is four levels up).
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
// startup can therefore outlive a tight smoke-test deadline before the child
// emits any output; 30s still detects a wedged process without confusing
// bounded CI contention with a lifecycle failure.
// The real-API workflow runs up to 14 e2e files at once.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
@@ -78,10 +78,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
}])
await waitForIdle(ctx, agent)
// World checks: the tool exists in the registry, was invoked as a real
// tool call, and its RESULT (the self-made execute actually running) is the
// reversed string. The model's prose is not asserted — the tool result is
// the world; the summary sentence is just the self-report.
// World checks: the tool exists in the registry, was invoked as a real tool call, and its
// RESULT (the self-made execute actually running) is the reversed string.
expect(ctx.tools.get('reverse_text')).toBeDefined()
const events = [...agent.session.events]
const calls = events.filter(event => event.type === 'tool/call')
@@ -6,22 +6,15 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for examples/cordis-agent: boot the REAL example
* through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` —
* the cordis Loader, `unwrapExports`, the full plugin tree INCLUDING the
* `@deepseek-ai/dsh-tool-cordis` package resolved by name (whose `inject`
* would crash a collapsed export shape at load, see docs/postmortem/0001) —
* then close stdin with no prompt and assert the ready banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called — that is why it runs
* without a real key: `llm-deepseek`'s apply() only requires a key to be
* PRESENT, and the absence of any prompt guarantees no network call. The
* with-key product proof lives in cordis-tools.e2e.ts.
* Keyless Loader-path smoke for examples/cordis-agent: boot the real example through the
* `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` — the cordis Loader,
* `unwrapExports`, the full plugin tree INCLUDING the `@deepseek-ai/dsh-tool-cordis` package
* resolved by name (whose `inject` would crash a collapsed export shape at load, see
* docs/postmortem/0001) — then close stdin with no prompt and assert the ready banner + a
* clean exit.
*/
// The dsh-stdio-agent bin (the demo:cordis entry) and this example's cordis.yml.
// The bin resolves its config-path arg from CWD; the test spawns from a temp
// cwd, so we pass the example config's ABSOLUTE path.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
@@ -29,10 +22,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig (root is three levels up).
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
// startup can therefore outlive a tight smoke-test deadline before the child
// emits any output; 30s still detects a wedged process without confusing
// bounded CI contention with a lifecycle failure.
// The real-API workflow runs up to 14 e2e files at once.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
+9 -31
View File
@@ -6,41 +6,20 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for examples/echo-agent: boot the REAL example
* through the `@deepseek-ai/dsh-stdio-agent` bin against this example's
* `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree),
* pipe a script of stdin lines, and assert the rendered stdout.
*
* This is the guard the per-file unit suite structurally cannot be: it drives
* the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core`
* bundle it loads, the app's in-package readline UI module, AND the
* example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path
* (see docs/postmortem/0001). The app itself carries no `inject`, so a stray
* `export default` would boot rather than crash here — the export SHAPE is
* pinned by the explicit unwrap assertion in the stdio-agent unit suite; this
* smoke proves the composed tree actually runs. It needs no API key — the
* `mock-echo` adapter never touches the network — so it runs in the default e2e
* gate.
*
* Both branches of mock-llm.ts are exercised: an `echo …` line (the tool
* round-trip → `ECHO: …`) and a plain line (the direct canned reply).
* Keyless Loader-path smoke for examples/echo-agent: boot the real example through the
* `@deepseek-ai/dsh-stdio-agent` bin against this example's `cordis.yml` (the cordis Loader,
* `unwrapExports`, the whole plugin tree), pipe a script of stdin lines, and assert the
* rendered stdout.
*/
// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml.
// The bin resolves its config-path arg from CWD; the test spawns from a temp
// cwd, so we pass the example config's ABSOLUTE path.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root
// tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from
// a temp cwd OUTSIDE the repo, so point tsx at the repo tsconfig explicitly
// (repo root is four levels up from examples/echo-agent/tests).
// Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root tsconfig `paths`
// map, which tsx finds by searching UP from cwd.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
// startup can therefore outlive a tight smoke-test deadline before the child
// emits any output; 30s still detects a wedged process without confusing
// bounded CI contention with a lifecycle failure.
// The real-API workflow runs up to 14 e2e files at once.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
@@ -67,9 +46,8 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: the example's cordis.yml loads the HMR plugin, which
// requires it (mirrors the `demo:echo` script). The whole point is to boot
// the example EXACTLY as it really runs, through the bin + Loader.
// --expose-internals: the example's cordis.yml loads the HMR plugin, which requires it
// (mirrors the `demo:echo` script).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
+1 -1
View File
@@ -13,4 +13,4 @@ Zed setup is the same as [acp-agent](../acp-agent/README.md) with this example's
- **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
- **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the filesystem tools stay unloaded for the same reason as `sandbox-agent`: they would bypass the bash sandbox.
Tests: `tests/escalation.e2e.ts` — keyless, it boots the real `cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end (both advertised with composition currents, switches honored and echoed as complete state, out-of-vocabulary values rejected); with a key and a usable runner, a scripted ACP client plays the human — the real model gets denied, escalates, the client answers `allow-once`, and the retried write must land on disk. `tests/acp.snapshot.ts` (the [shared snapshot kit](../../packages/support/acp-snapshot/) over this composition's `cordis.snapshot.yml` replay overlay) pins four scenarios as committed wire bytes: the keyless config-option exchange, the recorded `mode-switching` arc (the suite's pinned header — both switches, their prompt-section deltas, one "changed by the user" notice per knob, and a confined write landing under the switched mode), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap (or build the Landlock launcher) first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file).
`tests/escalation.e2e.ts` boots the real composition keylessly and exercises config-option advertisement, updates, and validation; with a key and usable runner it also world-verifies an allowed escalation. `tests/acp.snapshot.ts` pins config exchange, mode switching, and allowed and rejected approval branches through the shared snapshot kit. Replay executes recorded bash calls on the host runner, so Linux needs bubblewrap or Landlock while macOS uses Seatbelt. Fixtures avoid real denial stderr because that dialect is platform-specific.
@@ -3,54 +3,20 @@ import { fileURLToPath } from 'node:url'
import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
/**
* Snapshot suite for the SANDBOXED composition (`../cordis.yml`, swapped to
* the sibling `cordis.snapshot.yml` replay overlay by the bin under
* `DSH_SNAPSHOT=replay`). Replay swaps only the MODEL for the recorded
* transcript — every bash call re-executes for real under the host's actual
* runner (Seatbelt on macOS, bwrap on Linux CI: ci.yml's snapshot lane
* installs bubblewrap for exactly this), so the recorded scenarios double as
* cross-backend confinement regression: an allowed command a runner change
* starts denying fails replay outright. Their commands are limited to
* `cat`/`printf` shapes whose bytes are identical across those backends and
* across GNU/BSD userlands.
*
* Deliberately ABSENT: a scenario whose transcript carries a real sandbox
* DENIAL. The harness-authored `[sandbox: file access denied …]` marker is
* byte-stable, but the denied command's own stderr is the backend's dialect
* (bwrap EROFS "Read-only file system", Landlock EACCES "Permission
* denied", Seatbelt EPERM "Operation not permitted", GNU vs BSD phrasing on
* top), and stderr reaches both compared surfaces — such a fixture replays
* only on the platform that recorded it. The denial→marker path stays on
* dsh-tool-bash's unit tests and the real-kernel sandbox e2e legs
* (.github/workflows/sandbox.yml); the escalation scenarios below sidestep
* it by having the USER assert the prior denial, so the recorded model
* escalates without a platform-variant denial in the log.
* Snapshot suite for the sandboxed composition (`../cordis.yml`, swapped to the sibling
* `cordis.snapshot.yml` replay overlay by the bin under `DSH_SNAPSHOT=replay`).
*/
const SCENARIOS: Scenario[] = [
// Protocol-only (keyless, authored): the session config-option surface
// this composition adds — both advertised selects on session/new, the
// complete refreshed state every session/set_config_option answers with,
// and both rejection shapes — as committed wire bytes. No bash runs, so
// this one still replays on runner-less hosts.
// Protocol-only (keyless, authored): the session config-option surface this composition adds
// — both advertised selects on session/new, the complete refreshed state every
// session/set_config_option answers with, and both rejection shapes — as committed wire
// bytes.
{ name: 'config-options', hasModelTurn: false, recorded: false },
// The runtime mode-switching arc, and NECESSARILY the pinned-header
// scenario: an approval-policy switch rewrites its prompt section, and the
// resulting request/header-delta is legal only in the pinning scenario
// (the factory's uniformity guard). The pin commits this composition's
// full header — persona, tool schemas WITH the escalation fields — plus
// the approval delta and its "changed by the user" notice verbatim. The
// SANDBOX switch is deliberately silent (no section, no notice — the
// sandbox RFC's visibility asymmetry): the recorded arc proves it by
// BEHAVIOR, a confined write landing under the switched mode with no
// header change.
// The runtime mode-switching arc, and NECESSARILY the pinned-header scenario: an
// approval-policy switch rewrites its prompt section, and the resulting request/header-delta
// is legal only in the pinning scenario (the factory's uniformity guard).
{ name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1 },
// The approval wire end-to-end, under the DEFAULT read-only/ask (a switch
// would emit a header-delta the uniformity guard forbids here): the
// escalating bash call streams, session/request_permission attaches to it
// (allow-once / reject-once), and the scripted answer drives each branch —
// an approved run executes CONFINED under the granted workspace-write; a
// rejected one executes nothing and fails with the deterministic
// rejection text.
// Pin both approval branches under the default read-only/ask policy.
{ name: 'escalation-approved', hasModelTurn: true, recorded: true },
{ name: 'escalation-rejected', hasModelTurn: true, recorded: true },
]
@@ -18,21 +18,6 @@ import {
/**
* examples/sandbox-acp-agent end to end.
*
* Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as
* an ACP subprocess and drive initialize + session/new — the real-Loader-path
* guard (postmortem 0001) for THIS tree's export shapes, which now include the
* sandbox executor AND the approval service. No prompt is sent, so neither the
* model nor a sandbox runner is ever exercised.
*
* With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable
* platform runner): a scripted ACP client plays the human. The real model is
* denied under `read-only`, escalates with `sandbox_permissions` +
* `justification`, the bridge prompts THIS client over
* `session/request_permission`, the client answers `allow-once`, and the
* retried write must land ON DISK (world-verified). The session cwd is a temp
* dir under the platform temp area, which `workspace-write` grants — so either
* escalation target the model picks can land the write.
*/
const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
@@ -42,10 +27,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md).
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// A usable confining runner, probed the same way the executor suites do:
// bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict
// attempt would fail closed (SANDBOX_UNAVAILABLE) instead of producing the
// denial this flow starts from.
// A usable confining runner, probed the same way the executor suites do: bwrap on Linux,
// Seatbelt's sandbox-exec on macOS.
const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
timeout: 5_000,
stdio: 'ignore',
@@ -121,9 +104,8 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', ()
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-'))
spawned = spawnSandboxAcpAgent(workdir, 'reject-once')
const { client } = spawned
// A dummy key boots the adapter; no prompt is ever sent, so no model call
// and no sandbox runner probe happen. This drives the fiber tree the same
// way an editor would, which is what catches a broken export/inject shape.
// A dummy key boots the adapter; no prompt is ever sent, so no model call and no sandbox
// runner probe happen.
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(init.protocolVersion).toBe(PROTOCOL_VERSION)
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
+5 -5
View File
@@ -2,15 +2,15 @@
This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific.
- **Plugin export shape namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md).
- **Plugin export shape: namespace or default, never both.** Service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. The Loader otherwise discards the namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Read an optional, non-injected service with `ctx.get(name)`.** Use `ctx.<name>` only for injected services; its fiber-relative lookup is not safe for opportunistic sibling services ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **A plugin shipped through `cordis.yml` needs a real Loader-path test.** A hand-mounted plugin does not exercise `unwrapExports`; see [testing.md](../docs/testing.md).
Naming notes:
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (the export-shape rule above).
- A service `src/index.ts` default-exports the service class and named-exports public types; a function plugin named-exports its plugin namespace.
- `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; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)).
- Altered behavior updates the package README and JSDoc in the same commit; keep both concise under [the documentation standard](../docs/AGENTS.md).
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.
+11 -27
View File
@@ -1,16 +1,6 @@
/**
* `LocalBashExecutor`: the local-subprocess implementation of the
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
* own process group (see `./run.ts` for the plumbing and the agent-tool
* survey notes), tracks background tasks, and kills everything on dispose.
*
* TODO(permissions/sandbox): execution policy does NOT belong here — use
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
* § Extending The Harness) or implement a sandboxing `BashExecutor`.
* Reference points:
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
* seatbelt/landlock plus an execpolicy prefix-rule engine.
*
* `LocalBashExecutor`: the local-subprocess implementation of the `@deepseek-ai/dsh-bash`
* executor seam.
* @module @deepseek-ai/dsh-bash-local
*/
@@ -90,10 +80,9 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Kill every live process group and WAIT for the processes to close so
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
// held until the SIGKILL escalation lands. The base class already
// silenced listeners, so these kills complete without notices.
// Kill every live process group and WAIT for the processes to close so nothing outlives
// the fiber (HMR safety) — a TERM-trapping child is held until the SIGKILL escalation
// lands.
const pending: Promise<void>[] = []
for (const task of this.tasks.values()) {
if (task.status === 'running') {
@@ -154,23 +143,18 @@ export class LocalBashExecutor extends BashExecutor {
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our
// timeout cut the command short; any other abort — an upstream cancel, or a
// foreign (outer) deadline's timeout under nesting — is aborted. Scoping to
// our own code keeps a nested outer deadline from reading as our timeout.
// Mutually exclusive by construction — the fused signal reports one cause.
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our timeout cut the
// command short; any other abort — an upstream cancel, or a foreign (outer) deadline's
// timeout under nesting — is aborted.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
}
start(spec: BashExecSpec): BashTask {
// No timeout for background tasks (matches Claude Code, which detaches
// the timeout when backgrounding); callers stop tasks via kill() — or
// via spec.signal, which the seam contract honors for background runs
// too (runBash wires it to the group kill). No deadline is created here,
// so spec.timeoutMs is ignored by design — background tasks stay
// timeout-free (see the timeout-library RFC).
// No timeout for background tasks (matches Claude Code, which detaches the timeout when
// backgrounding); callers stop tasks via kill() — or via spec.signal, which the seam
// contract honors for background runs too (runBash wires it to the group kill).
const running = runBash({
command: spec.command,
cwd: spec.workdir,
+20 -90
View File
@@ -1,23 +1,6 @@
/**
* Process plumbing for the local bash executor: spawn, output collection
* with tail-keep + spill-to-disk truncation, and process-group kill with
* SIGTERM→SIGKILL escalation.
*
* Everything here is deliberately free of Cordis concepts so it can be unit
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
*
* runBash owns NO timing: it kills the process group when its `spec.signal`
* fires and does not distinguish a timeout from a cancel. The executor fuses
* timeout + upstream cancellation into that one signal via
* `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
* signal afterward — the timing/classification half is shared, the kill is not.
*
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
* the package README): spawn-per-call with `detached: true` so the child
* leads its own process group; kills target the group (`kill(-pid)`) so
* pipelines and subshells die with the parent. SIGTERM first, SIGKILL after a
* grace period (OpenCode's escalation; Codex/pi jump straight to SIGKILL).
*
* Process plumbing for the local bash executor: spawn, output collection with tail-keep +
* spill-to-disk truncation, and process-group kill with SIGTERM→SIGKILL escalation.
* @module dsh-bash-local/run
*/
@@ -50,18 +33,9 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* `process.env` minus credential-shaped vars, plus the model-friendly
* overrides, plus any caller-supplied `extra` entries.
* `process.env` minus credential-shaped vars, plus the model-friendly overrides, plus any
* caller-supplied `extra` entries.
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so an explicit caller entry wins even when its name matches the
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
* credentials leaking into a spawned command; a caller that explicitly sets a
* var named a value it already holds, not that ambient secret). `extra` is set
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
* builds its request from named fields only and does not forward model input
* here (see its README, § "The tool builds its request from named args only").
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
* @returns the environment to hand to `spawn` for the child process.
*/
@@ -262,10 +236,8 @@ export class OutputCollector {
try {
closeSync(this.spillFd)
} catch {
// close can surface delayed writeback failures (for example EIO/ENOSPC)
// after writeSync appeared to succeed. Keep finalize total so runBash's
// close handler still resolves, but stop advertising a spill file that
// may be missing its tail.
// close can surface delayed writeback failures (for example EIO/ENOSPC) after writeSync
// appeared to succeed.
this.spillFile = undefined
}
this.spillFd = undefined
@@ -275,13 +247,9 @@ export class OutputCollector {
}
/**
* Send `sig` to the process GROUP led by `pid` (requires the child to have
* been spawned with `detached: true`). NEVER throws: kills race process exit
* by design (ESRCH), and the other failure modes (EPERM from setuid
* children, …) fire inside timer callbacks where a throw would crash the
* host process — a kill that cannot be delivered is reported by the process
* NOT dying, which callers already handle via escalation/timeouts. No-op for
* non-positive pids (spawn never started a process).
* Send `sig` to the process GROUP led by `pid` (requires the child to have been spawned with
* `detached: true`).
*
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
* @param sig - the signal to deliver to the whole group.
*/
@@ -311,24 +279,13 @@ export interface RunningBash {
}
/**
* Spawn `bash -c <command>` in its own process group and collect output.
*
* Outcome semantics: the returned promise REJECTS only for spawn-level
* failures (bad cwd → ENOENT, missing binary, pre-aborted signal); every
* runtime outcome — nonzero exit, timeout kill, abort kill, signal death —
* RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
* shape one consistent report for the model.
*
* XXX(stateful-shell): per the agent-tool survey there are two proven
* stateful designs worth revisiting — Claude Code persists ONLY cwd between
* calls (captures `pwd -P` after each command), and Codex keeps whole PTY
* exec sessions addressable via session ids + stdin writes. We deliberately
* spawn a fresh non-login `bash -c` per call for determinism (no rc files,
* no inherited shell state); revisit when real workflows demand it.
* @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
* @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
* @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
* Spawn one isolated `bash -c` process group and collect its output.
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
* @param spec - fully resolved command, cwd, limits, and cancellation.
* @param internals - test-only process and spill-directory overrides.
* @returns live process handle and outcome promise.
*/
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const spillDir = internals.spillDir ?? privateSpillDir()
@@ -336,16 +293,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
// stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
// (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
// and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
// socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
// /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
// (every model-driven call) must keep /dev/null rather than regress to a socket.
// Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
// typed `spawn` overload infer non-null stdout/stderr, which the
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
// stderr the non-null `Readable` the collectors attach to without a cast).
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
@@ -358,8 +306,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
// the 'error' handler rejects `done` and kills become no-ops via pid -1.
// Failed spawns use pid -1 so kill remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
@@ -368,27 +315,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
// runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
// deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
// listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
// timeout or an upstream cancel is classified by the executor from that
// signal, not tracked here.
// The executor owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
// stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
// handler must exist whenever we write: an unhandled 'error' on the stream
// would throw and crash the host. We swallow the error rather than reject
// `done`, and that is correct for ANY stdin-write error, not just the common
// one — the stdin write is BEST-EFFORT, while the command's authoritative
// outcome is its exit code + captured output, which the `close` handler reports
// regardless of whether the write landed. The expected case is EPIPE (the child
// exited without reading, so closing our end of a still-full pipe fails); a rare
// non-EPIPE pipe fault means the command ran with incomplete stdin, and it
// surfaces that itself through its own exit/output (e.g. a hook that gets
// truncated JSON errors out) — rejecting here would instead discard that real
// output and turn it into an opaque infrastructure error, which is worse.
// Stdin writes are best-effort; process exit and captured output remain authoritative.
if (child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
@@ -396,8 +327,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
// meaningful output follows; clean up and reject.
// No meaningful close outcome follows a spawn failure.
cleanup()
reject(error)
})
+4 -9
View File
@@ -189,12 +189,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// The no-stdin path must stay observationally identical to the pre-seam
// `ignore` default: a command that probes stdin's file type sees a char
// device (/dev/null). Regressing to an always-open pipe would make fd 0 a
// socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
// `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
// fd 0 is that pipe (a socket), as it must be to carry them.
// The no-stdin path must stay observationally identical to the pre-seam `ignore` default: a
// command that probes stdin's file type sees a char device (/dev/null).
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
@@ -219,9 +215,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits immediately without reading; closing our end of a stdin
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
// swallow it: `done` resolves normally with the child's real exit.
// The child exits immediately without reading; closing our end of a stdin pipe still
// holding ~1MiB triggers EPIPE on the write.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
+19 -110
View File
@@ -1,43 +1,6 @@
/**
* `SandboxBashExecutor`: the sandbox-consuming implementation of the
* `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
* the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
* configured {@link SandboxMode}: the executor hands the provider the exact
* `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
* argv instead. WHICH platform runner confines it — and whether one is
* usable at all (the provider fails CLOSED with a structured
* `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
* provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
*
* Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
* kills, timeout escalation, output collection and spill files, background
* tasks, the credential scrub — are the local implementation's, verbatim.
* This package adds only the seam consumption and the result facts, which is
* exactly the split the capability seam was designed for (a sandboxing
* executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
* swapping the confinement backend never touches this package).
*
* A failed run whose stderr carries the selected backend's own denial
* dialect (the signatures the provider stamps on every wrap) is classified
* as a sandbox denial on `BashRunResult.sandbox`, and every confined result
* also carries how completely the selected runner enforces the mode
* (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
* backend's RUNNER-FAILURE signature instead means the sandbox itself broke
* and the command never ran: the foreground path re-throws it as the
* structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
* provider's confine-time throw), a settled background task stamps
* `sandbox.runnerFailed` — either way a broken sandbox can never read as a
* failing command, and the command never slips through unconfined.
*
* Deny-only at the seam, escalation at the tool: a denial is a reported FACT
* here, and the one-shot user-approved escalated retry of a denied action
* (docs/rfc/implemented/feature/2026-07-06-sandbox.md) is driven by
* `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
* per-call `sandboxMode` override it honors in {@link resolve}: an escalated
* call runs (and classifies, and reports) under ITS granted mode while every
* neighboring call keeps its session's standing mode (or the configured
* default when that session has no override).
*
* `SandboxBashExecutor`: the sandbox-consuming implementation of the `@deepseek-ai/dsh-bash`
* executor seam.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -79,24 +42,9 @@ export function shellQuote(text: string): string {
}
/**
* Conservative sandbox-denial classifier: a run counts as denied only when it
* FAILED (nonzero exit — a signal kill is not a denial) and its stderr
* carries one of the SELECTED BACKEND's own denial signatures — the dialect
* the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
* `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
* under Landlock's EACCES, `Operation not permitted` under Seatbelt's
* EPERM). Matching the backend's dialect rather than a cross-backend union
* keeps the classifier from claiming denials the active backend never
* produces (bare EPERM text under a Linux runner names non-file boundaries —
* mount, kill, ptrace — that fail the same way unsandboxed). Text inference
* is the fallback signal until a runner provides a structured one (which
* wins once it exists); it errs toward NOT claiming a denial, and its known
* residual imprecision is non-sandbox text in the active dialect (an ssh
* auth failure reads as a denial under Landlock, a refused `kill` under
* Seatbelt).
* Classify a nonzero run using the selected backend's denial signatures.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's denial dialect, case-insensitive
* stderr substrings.
* @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings.
* @returns whether the run's failure reads as a sandbox denial.
*/
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
@@ -104,17 +52,7 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Runner-failure classifier: a failed run whose stderr carries the SELECTED
* BACKEND's own runner-failure signature (`ConfinedArgv.
* runnerFailureSignatures`: the runner's error prefix, which also matches
* the shell's runner-not-found message) means the SANDBOX itself failed and
* the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
* error text can contain denial words (an unopenable grant root reports
* `Permission denied`) — and surfaced as the fail-closed
* `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
* on a settled background task. Same conservative-text-inference stance and
* residual imprecision as the denial classifier (a failing task that itself
* prints the runner's prefix reads as a runner failure).
* Classify a nonzero run using the selected backend's runner-failure signatures.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's runner-failure signatures,
* case-insensitive stderr substrings.
@@ -137,15 +75,7 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r
}
/**
* Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
* the whole swap — the tool layer is untouched). Its configured mode is the
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
* request, while an approved escalation may stamp a strictly wider mode for
* one call. The tool's per-agent prompt section states that same effective
* mode, and each run's `result.sandbox` reports what actually executed plus
* enforcement completeness.
* Sandbox-consuming bash executor.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox']
@@ -163,15 +93,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
private readonly mode: SandboxMode
private readonly workspaceRoot: string
/**
* Per-task facts, keyed by task id from `start()` until the settle stamp
* consumes them: the mode the task runs under (per-call — an escalated task
* differs from its neighbors) plus its wrap facts. The seam returns facts
* PER WRAP — a provider may legally vary enforcement or dialect between
* calls — so overlapping background tasks must each classify against their
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
* an earlier task's facts before it settles. A `danger-full-access` task
* has NO entry (nothing confined it), which is what the settle stamp keys
* off.
* Per-task facts, keyed by task id from `start()` until the settle stamp consumes them: the
* mode the task runs under (per-call — an escalated task differs from its neighbors) plus
* its wrap facts.
*/
private readonly taskFacts = new Map<BashTaskId, {
mode: ConfinedSandboxMode
@@ -215,11 +139,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
const confined = this.confine(spec.command, mode)
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial: the sandbox itself failed and the
// command NEVER RAN — surface the same structured fail-closed error a
// confine-time discovery throws (late detection, same outcome), with
// the runner's own first stderr line as the cause. Returning it as a
// task result would let a broken sandbox read as a failing command.
// Runner failure outranks denial: the sandbox itself failed and the command never RAN —
// surface the same structured fail-closed error a confine-time discovery throws (late
// detection, same outcome), with the runner's own first stderr line as the cause.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
}
@@ -230,11 +152,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// Same stamped-by-resolve invariant as run().
const mode = spec.sandboxMode as SandboxMode
if (mode === 'danger-full-access') return super.start(spec)
// Sandbox facts are stamped at settle time by {@link notifyTaskDone}
// (denial classification runs against the settled task's collected
// stderr). The map entry lands synchronously after spawn, strictly
// before the earliest possible settle (a process exit reaches us no
// sooner than the next tick).
// Sandbox facts are stamped at settle time by {@link notifyTaskDone} (denial classification
// runs against the settled task's collected stderr).
const confined = this.confine(spec.command, mode)
const task = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
@@ -243,27 +162,17 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
/**
* Stamp the sandbox facts BEFORE completion listeners run: the base
* executor notifies from inside the task's settle path, so overriding the
* notification point is what makes `task.sandbox` visible to `onTaskDone`
* consumers and `done` awaiters alike. Each task classifies against the
* facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
* per-task map here — settle is the entry's end of life): with per-call
* escalation, tasks under different modes settle side by side, so keying
* anything off the configured default would misreport them. A
* `danger-full-access` task has no map entry and carries no facts (nothing
* confined it); a signal-killed task (null exit code) is never a denial,
* mirroring the foreground classifier.
* Stamp the sandbox facts before completion listeners run: the base executor notifies from
* inside the task's settle path, so overriding the notification point is what makes
* `task.sandbox` visible to `onTaskDone` consumers and `done` awaiters alike.
*/
protected override notifyTaskDone(task: BashTask): void {
const facts = this.taskFacts.get(task.id)
if (facts !== undefined) {
this.taskFacts.delete(task.id)
const stderr = this.collectedStderr(task.id)
// Runner failure outranks denial (the command never ran; the runner's
// own error text can contain denial words). A settled task has no
// error channel left, so the fact IS the surface here — the foreground
// path throws instead.
// Runner failure outranks denial (the command never ran; the runner's own error text can
// contain denial words).
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
task.sandbox = {
mode: facts.mode,
+3 -14
View File
@@ -9,20 +9,9 @@ import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
* KEYLESS consumer-integration proof under bwrap: the REAL
* `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
* so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
* driven through the executor's public run/start paths. Verifies the WORLD
* (files exist or don't) plus the stamped result facts — in particular that
* bwrap's EROFS denial text classifies as `denied: true` through the
* wrap-carried dialect; the backend-only confinement proofs live with
* `@deepseek-ai/dsh-sandbox-local`.
*
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
* host that denies unprivileged user namespaces.
*
* HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
* paths outside it prove the workspace-root boundary.
* Keyless consumer-integration proof under bwrap: the real `LocalSandboxProvider` (nothing
* forced — bwrap is the ladder's first rung, so a passing probe selects it) underneath the
* real `SandboxBashExecutor`, driven through the executor's public run/start paths.
*/
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
@@ -1,11 +1,5 @@
/**
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
* `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
* policy hand-off, fail-closed propagation, classification, and fact
* stamping all deterministic without any real runner; the real-provider
* integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
* with plain unix permissions (a 0555 directory), which exercises the same
* stderr signature the classifier keys on.
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
@@ -286,10 +280,7 @@ describe('background sandbox facts', () => {
})
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
// The seam returns facts PER WRAP — a legal provider may vary them
// between calls. The slow task settles AFTER the quick one started, so a
// latest-wrap field would classify its denial against the quick task's
// dialect (missing it) and stamp the wrong enforcement.
// The seam returns facts per WRAP — a legal provider may vary them between calls.
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
{ enforcement: 'full', denialSignatures: ['read-only file system'] },
+1 -1
View File
@@ -32,6 +32,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
The seam owns per-session sandbox overrides through the log-only `bash/sandbox-mode` event, `effectiveSandboxMode`, and `setSandboxMode`; writers preserve turn enclosure, and replay restores the last override. `BashTaskId` and `OwnerToken` are distinct brands. Foreground `run` returns exit, timeout, cancellation, output, and optional sandbox facts; background `start` and `readOutput` use task records. A sandboxing executor reports the executed mode, conservative denial classification, and enforcement completeness. See [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md) for full shapes.
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+12 -50
View File
@@ -1,16 +1,6 @@
/**
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
* bash backend does — run commands, manage background tasks — without saying
* HOW. Implementations subclass {@link BashExecutor} and register themselves
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
* is the first. Future implementations swap in sandboxes, containers, or
* remote exec servers without touching the tool schemas that consume them
* (`@deepseek-ai/dsh-tool-bash`).
*
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
* surveyed agents: pi hides execution behind a `BashOperations` interface
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
*
* The bash executor seam (`ctx.bash`): an abstract service defining what a bash backend does —
* run commands, manage background tasks — without saying how.
* @module @deepseek-ai/dsh-bash
*/
@@ -39,25 +29,9 @@ declare module 'cordis' {
}
/**
* Abstract bash execution service. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.bash` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} REJECTS only for infrastructure failures (unusable workdir,
* missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
* a failed command is the tool layer's job, not an exception.
* - {@link start} returns immediately; no timeout applies to background
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
* Completion must fire the {@link onTaskDone} listeners exactly once per
* task, and must NOT fire after the service is disposed.
* - {@link readOutput} is incremental: consecutive reads never re-deliver
* output. Implementations bound their buffers; reads that lost data flag
* `lossy` and point at full-stream spill files when available.
* - Disposal kills every running task and awaits their exit (no orphan
* processes survive `fiber.dispose()`).
* Abstract bash execution service. Subclass, implement the abstract methods, and load the
* subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a
* second throws, which is cordis' standard duplicate-service behavior).
*/
export abstract class BashExecutor extends Service {
private listeners = new Set<BashTaskListener>()
@@ -74,14 +48,10 @@ export abstract class BashExecutor extends Service {
}
/**
* The sandbox mode this executor confines commands under BY DEFAULT, or
* `undefined` when it does not sandbox at all — the capability fact the
* tool and ACP layers read to advertise sandbox controls honestly. The
* getter proves a sandboxing executor is mounted and supplies its fallback
* mode; a session override may make the effective mode narrower or wider,
* so strict escalation widening is checked per call rather than encoded in
* this default-relative capability fact. The base class reports
* `undefined`; a sandboxing implementation overrides the getter.
* The sandbox mode this executor confines commands under BY DEFAULT, or `undefined` when it
* does not sandbox at all — the capability fact the tool and ACP layers read to advertise
* sandbox controls honestly.
*
* @returns the configured default mode of a sandboxing executor;
* `undefined` for an executor that never confines.
*/
@@ -125,17 +95,9 @@ export abstract class BashExecutor extends Service {
abstract get(id: BashTaskId): BashTask | undefined
/**
* The opaque OWNER token recorded for a background task at {@link start}
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
* OR a known-but-ownerless task. The executor stores and returns the token
* verbatim — it never interprets it; the access POLICY (who may read/kill a
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
* known-but-unowned into the same `undefined` is fine: the consumer's access
* gate treats `undefined` as "open", and a genuinely unknown id then fails
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
* Storing ownership in the executor (disposed with ITS fiber) — not in the
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
* The opaque OWNER token recorded for a background task at {@link start} (from the {@link
* BashExecSpec}'s `owner`), or `undefined` for an unknown id OR a known-but-ownerless task.
*
* @param id - the background task id to look up ownership for.
* @returns the token recorded at start, verbatim; undefined for an unknown
* id or a known-but-ownerless task.
+1 -13
View File
@@ -1,17 +1,5 @@
/**
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
* one `bash/sandbox-mode` event on the session it applies to;
* `effective = fold(events) ?? the executor's configured default`, so an
* override survives restart by replay, two sessions can never see each
* other's state, and there is no external config store. The event is
* log-only (the `approval/*` precedent): the model learns the mode from the
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
* never from the event itself. EXECUTION honors the fold in the tool layer —
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
* (weakest-precedence: an escalation grant for the call outranks it) — the
* executor itself stays a config-fixed default plus per-call overrides.
*
* Per-session sandbox-mode override: the session log as the store.
* @module dsh-bash/session-mode
*/
+2 -11
View File
@@ -125,17 +125,8 @@ export interface BashExecRequest {
*/
owner?: OwnerToken | undefined
/**
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
* consumer sets it only from an explicit policy source — an
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
* session's standing override folded from its own `bash/sandbox-mode`
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
* choice). A sandboxing executor confines THIS call under the given mode;
* a non-sandboxing executor carries the field and confines nothing (the
* tool layer stamps neither escalation nor overrides without a sandboxing
* executor — see {@link BashExecutor.sandboxMode}).
* Explicit per-call sandbox-policy input, overriding the executor's configured default mode
* for this call.
*/
sandboxMode?: SandboxMode | undefined
}
+2 -2
View File
@@ -38,7 +38,7 @@ The owning agent's session token (`session.header.id`) is stamped onto the task
## UI presentation
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
UI presentation is tool-owned through `presentCall` and `presentResult`. Foreground `bash` uses a terminal card whose title is the command, optional description is separate, and cwd follows `workdir` or the session; its result carries raw output and exit or signal data. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe, and malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics.
## Background completion notices
@@ -52,7 +52,7 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks
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).
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
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.
## Per-session mode switching
+30 -190
View File
@@ -1,57 +1,8 @@
/**
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
* schema + text shaping — every process concern lives behind the `ctx.bash`
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
* executor implementations swap in without touching what the model sees.
*
* Background notifications: when a background task completes, a short notice
* is injected into the owning agent's session (`agent.inject()` — the
* documented context seam). Injection is durable context for the NEXT model
* request, not a wake-up: an idle agent stays idle until something sends a
* message, which is why the tool descriptions tell the model to poll with
* `bash_output`.
*
* Task ownership: a background task's OWNER is an opaque token — the owning
* agent's `session.header.id` — passed to the executor at spawn
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
* !== caller`); an unowned task (no token — started by a non-agent caller) is
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
* multi-session ACP (RFC 011) this token check is the fence that stops one
* session's agent from reading or killing another session's background task.
*
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
* fiber), rather than in this plugin, is what makes ownership survive a
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
* to this plugin's `apply`, so a
* completion landing during the reload gap still drops its one notice — the
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
*
* Commands run with the executor's full authority unless a sandboxing
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
* docs/architecture.md § Extension And Composition. Under a sandboxing
* executor this plugin also advertises the ESCALATION surface
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
* sandbox denied may be retried once under a strictly wider mode, resolved
* through `ctx.approval` BEFORE anything executes and failing closed on every
* unanswerable path. The fields exist only when the mounted executor reports
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
* that the composition cannot honor.
*
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
* call is stamped `escalation grant > session override > executor default`.
* The prompt deliberately does NOT state the mode and no switch is narrated:
* the model learns the boundary from the denial marker (which names the mode
* it ran under) exactly when it matters, instead of preemptively refusing
* work a standing declaration would discourage.
*
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure schema + text shaping
* — every process concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`),
* so sandbox/permission/remote executor implementations swap in without touching what the
* model sees.
* @module @deepseek-ai/dsh-tool-bash
*/
@@ -154,14 +105,7 @@ const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
/**
* The bash tool's static description. The base text is byte-stable regardless
* of composition (it is part of the pinned snapshot header); the escalation
* teaching rides only when the mounted executor actually honors the fields —
* it names the ONE sanctioned exception to the base text's "do not retry
* another way" rule. Its deference clause ("If the session states approval
* prompts are disabled…") points at the approval plugin's never-policy prompt
* sentence by meaning, not by parsed wording — a rendezvous kept working by
* that sentence continuing to open with the approvals-disabled claim.
* The bash tool's static description.
*/
function bashDescription(escalationModes: readonly SandboxMode[]): string {
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
@@ -192,15 +136,14 @@ function streamText(output: CollectedOutput): string {
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* Shape one finished run into the text the model sees: stdout, then a marked stderr
* section, then exit-status markers.
*
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
* @param escalationModes - the escalation targets this composition advertises; non-empty
* adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any
* timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
@@ -247,33 +190,10 @@ export function renderResult(
return body + markers.join('\n')
}
// ---------------------------------------------------------------------------
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
// renders a bash call's pending and completed states. They are display-only and
// pure — a UI may call them during live streaming AND a session-log replay.
// ---------------------------------------------------------------------------
// UI presentation (tool-owned).
/**
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
* cwd (this PURE presenter, args only, can't see it).
* Pending-state presentation for a `bash` call.
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
@@ -300,26 +220,7 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* return a `generic` result whose content is the fenced ```console block. A
* finished foreground run returns a `terminal` result carrying the RAW output
* and the parsed exit status; the BRIDGE derives the fenced fallback from
* `output` for a UI without terminal support, so the tool does not double-encode
* it. A non-text result (unexpected for bash) falls through to `undefined`.
* Completed-state presentation for a `bash` call.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
@@ -337,29 +238,8 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
* still indistinguishable from a real marker and would show a wrong pill. This is
* display-only (execution and the model-facing text are unaffected) and narrow;
* the complete fix is to persist a structured exit on the result event, which the
* RFC names as the escape hatch.
* Recover the structured exit status from a rendered `renderResult` string — the inverse of
* the status markers it appends.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
@@ -375,15 +255,7 @@ function presentTaskCall(verb: string, args: { task_id: string }): GenericCallVi
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
* Resolve the working directory for a bash call.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
@@ -443,14 +315,6 @@ export function apply(ctx: Context): void {
}
// Background completion → inject a notice into the owning agent's session.
// Find the live agent by its session id token via the agent registry, read
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
// this listener runs from `task.done.then` on the bash fiber — a foreign
// fiber — where the `ctx.agents` property proxy would throw through the
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
// registry mounted (`undefined`) → drop the notice. Match on
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
// from its session id, and the owner token IS the session id.
ctx.bash.onTaskDone((task) => {
const ownerToken = ctx.bash.ownerOf(task.id)
if (ownerToken === undefined) return
@@ -462,21 +326,14 @@ export function apply(ctx: Context): void {
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between task
// completion and this injection (ReactLoopAgent.inject throws
// `agent "<id>" is disposed`). That race is benign — drop the notice.
// Anything else is a real bug and must surface, not be swallowed.
// The one expected failure: the agent was disposed between task completion and this
// injection (ReactLoopAgent.inject throws `agent "<id>" is disposed`).
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
})
// The escalation surface exists whenever the mounted executor confines.
// Its enum is the closed target vocabulary, deliberately NOT cut down by
// the configured default: a session may switch to a narrower effective mode
// while sharing this globally registered schema. Strict widening therefore
// belongs to the per-call check below. An executor swap restarts this fiber
// (static inject) and re-registers the schema.
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -504,20 +361,13 @@ export function apply(ctx: Context): void {
* deployment without it degrades per call, never at registration.
*/
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
// Schema validation only checks ADVERTISED keys, so an unadvertised
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
// human is never prompted to "escalate" a sandbox that is not there. When
// the fields ARE advertised, the registry's SchemaSpec enum has already
// pinned `mode` to this ladder for every caller.
// Schema validation only checks ADVERTISED keys, so an unadvertised `sandbox_permissions`
// (no sandboxing executor) still reaches execute — reject it here so a human is never
// prompted to "escalate" a sandbox that is not there.
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
// Strict widening is an EXECUTION check against the call's effective
// mode — session override ?? executor default, the same fold ordinary
// calls are stamped with — deliberately not a schema constraint (the
// enum is the closed target vocabulary; the effective mode is per-call
// truth). A non-widening request fails closed here and never prompts a
// human.
// Reject sandbox widening against the call's effective mode before requesting approval.
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`)
@@ -580,14 +430,9 @@ export function apply(ctx: Context): void {
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// `description` is display/logging metadata only (surfaced to UIs via
// the tool/call session event); it is intentionally NOT forwarded to
// ctx.bash and has no effect on execution.
// An escalating call resolves approval BEFORE anything executes; every
// non-grant outcome throws its distinct error text and runs nothing.
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
// An ordinary call carries the session's standing override instead —
// grant > session override > executor default (see sessionOverride).
// `description` is display/logging metadata only (surfaced to UIs via the tool/call
// session event); it is intentionally not forwarded to ctx.bash and has no effect on
// execution.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
@@ -603,10 +448,8 @@ export function apply(ctx: Context): void {
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {
// Stamp the owner token (the agent's session id) onto the spec so the
// executor stores it on the task — the isolation fence for bash_output/
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
// to fence).
// Stamp the owner token (the agent's session id) onto the spec so the executor stores
// it on the task — the isolation fence for bash_output/ bash_kill.
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
return [{ type: 'text', text: `started background task ${task.id}` }]
}
@@ -645,10 +488,7 @@ export function apply(ctx: Context): void {
// error; a settled task's read carries the marker instead.
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
} else if (read.task.sandbox?.denied) {
// Mirrors the foreground result marker (and its same-turn escalation
// hint). Background denials are only classifiable once the task
// settles (the classifier needs the whole stderr), so the marker
// rides every read that sees the settled task.
// Mirrors the foreground result marker (and its same-turn escalation hint).
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
if (escalationModes.length > 0) {
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
+22 -54
View File
@@ -56,12 +56,7 @@ async function setup() {
*/
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
// owner token IS the session id, so the notice path must find the agent by
// `session.header.id`, NOT the registry key. Using distinct values here makes
// the test fail if a regression matched on the wrong field (a same-value fake
// would pass either way — the "hits the line but not the scenario" trap).
// Distinct ids ensure notices match the session owner token, not the registry key.
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
const dispose = ctx.agents.register(agent)
const list = fakeAgentDisposers.get(ctx) ?? []
@@ -416,9 +411,8 @@ describe('background tools', () => {
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
const ctx = await setup()
const inject = vi.fn()
// The notice path looks the agent up in ctx.agents by its session token, so
// the agent must be REGISTERED (not merely passed to execute). Mount a
// registry and register a fake whose session.header.id IS the owner token.
// The notice path looks the agent up in ctx.agents by its session token, so the agent must
// be REGISTERED (not merely passed to execute).
const agent = registerFakeAgent(ctx, 'bg', inject)
const started = await ctx.tools.execute({
@@ -481,11 +475,9 @@ describe('background tools', () => {
})
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
// disposes while the background task is still running. The owner token is
// still on the task, but no live agent carries it anymore, so the registry
// lookup finds nothing and the notice is dropped (no throw).
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its per-session agent
// — e.g. the ACP session disconnects and its AgentHandle disposes while the background task
// is still running.
const ctx = await setup()
const inject = vi.fn()
const agent = registerFakeAgent(ctx, 'bg', inject)
@@ -515,11 +507,9 @@ describe('background task ownership (cross-session isolation)', () => {
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
// each agent needs a DISTINCT session id, else every fake yields the same
// token and the isolation tests pass for the wrong reason (all tasks owned by
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
// it.
// Ownership is by TOKEN (session.header.id), not agent object identity — so each agent needs
// a DISTINCT session id, else every fake yields the same token and the isolation tests pass
// for the wrong reason (all tasks owned by the same token).
const fakeAgent = (sessionId: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
@@ -599,11 +589,8 @@ describe('background task ownership (cross-session isolation)', () => {
})
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
// task survive) preserves ownership. This is the regression guard: a
// plugin-local map would make B accessible after reload, and this test would
// catch it.
// The owner token lives on the TASK inside the executor (dsh-bash fiber), not in a
// tool-bash plugin-local map.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -822,11 +809,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
const ctx = await setup()
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
// A successful command can print text that looks like a marker. renderResult
// for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
// own tail is `[exit code: 5]`. The parse requires a LEADING newline before
// the marker (renderResult always inserts one before a REAL marker), so this
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
// A successful command can print text that looks like a marker. renderResult for a clean
// exit 0 appends NOTHING (and no trailing newline), so the body's own tail is `[exit code:
// 5]`.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Same for a fake signal marker with no leading newline.
@@ -889,26 +874,17 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
const ctx = await setup()
// defineTool wraps presentCall to soft-validate against the schema and fall
// back to undefined (a generic UI presentation) rather than throwing on the
// display path — it may run on replay of arbitrary logged args. The
// ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
// defineTool wraps presentCall to soft-validate against the schema and fall back to
// undefined (a generic UI presentation) rather than throwing on the display path — it may
// run on replay of arbitrary logged args.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* never spread unknown tool-call keys into it. This guard's job is to catch a
* future refactor that blindly forwards `...args` — which would silently thread
* model input into the post-scrub `env` merge — 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()` is
* unused here.
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a test can
* assert what the model-facing tool DID and DID NOT forward.
*/
class RecordingBashExecutor extends BashExecutor {
readonly requests: BashExecRequest[] = []
@@ -951,12 +927,7 @@ describe('the model-facing bash tool builds its request from named args only (no
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Extra args: the model includes `env` and `stdin` keys hoping they reach the
// executor. The bash tool's schema ignores unknown keys, and execute() builds
// the request from only command/workdir/timeoutMs/signal — so the recorded
// request carries NEITHER. (Not a security wall — the model could set an env
// var or feed stdin via shell syntax anyway; this just keeps the request
// shape honest so a future `...args` spread can't silently forward input.)
// Extra args: the model includes `env` and `stdin` keys hoping they reach the executor.
await ctx.tools.execute({
callId: CallId('no-forward-1'),
name: 'bash',
@@ -1447,11 +1418,8 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
})
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
// The blocker scenario: a workspace-write default with a read-only
// override — the sensible escalation is workspace-write, which a
// default-relative ladder could not even express. The static target
// vocabulary advertises it and the execution check accepts it as
// strictly wider than the CALL's effective (overridden) mode.
// The blocker scenario: a workspace-write default with a read-only override — the sensible
// escalation is workspace-write, which a default-relative ladder could not even express.
const ctx = await setupModal('workspace-write', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const seen: (string | undefined)[] = []
@@ -1,12 +1,7 @@
/**
* Worker-side execution logic, written as plain functions over an injected
* port so the unit suite can run every line IN-PROCESS against a fake port
* (a real worker thread is a separate V8 isolate the coverage provider
* cannot observe). The real worker entry (`worker.ts`) is a thin
* self-executing glue file over {@link runWorkerMain}, excluded from
* coverage the same way `bin.ts` entrypoints are, and exercised end-to-end
* by the integration tests that spawn real workers.
*
* Worker-side execution logic, written as plain functions over an injected port so the unit
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
* V8 isolate the coverage provider cannot observe).
* @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
*/
@@ -96,12 +91,9 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
/**
* Redirect a stream's `write` into the log buffer (the program-visible
* `process.stdout`/`process.stderr` in the real worker), so raw writes land
* in emission order alongside console output instead of racing down a pipe.
* The shim keeps Node's `write(chunk[, encoding][, callback])` contract: the
* callback fires asynchronously once the chunk is admitted (a program
* awaiting flush completion must complete, not sit until the wall timeout),
* even for writes the exhausted budget drops.
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
* alongside console output instead of racing down a pipe.
*
* @param logs - the buffer captured writes are pushed into.
* @param stream - the stream whose `write` slot is patched.
* @param source - the log source the captured writes are attributed to.
@@ -152,16 +144,11 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string {
}
/**
* Prepare the program's completion value for the done message: a value whose
* MEASURED cross-boundary size fits `maxValueBytes` crosses raw — exact
* bytes for a string, the structured-clone wire size (`v8.serialize`) for
* everything else, so a huge container whose BOUNDED inspect rendering
* happens to be small cannot smuggle itself past the cap. Anything else
* (non-cloneable, or oversized) is REPLACED by its bounded `util.inspect`
* rendering, byte-truncated ({@link truncateUtf8Bytes}) with an in-band
* marker — the seam contract's "a non-transferable value is replaced by a
* string rendering", extended to oversized ones so a huge return cannot
* flood the host.
* Prepare the program's completion value for the done message: a value whose MEASURED
* cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the
* structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose
* BOUNDED inspect rendering happens to be small cannot smuggle itself past the cap.
*
* @param value - the program's completion value.
* @param maxValueBytes - the byte cap for the value.
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
@@ -215,13 +202,10 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
}
/**
* Build the binding namespace objects the program sees: one null-prototype
* global per namespace, each declared name an own enumerable async function
* that bridges over the port (`__proto__`/`constructor`/`toString` are
* ordinary keys, never prototype collisions). A non-cloneable argument
* rejects that one call with a descriptive error; the host's reply (`ok`
* false) rejects it likewise, so a failed tool call surfaces in the program
* as an ordinary promise rejection.
* Build the binding namespace objects the program sees: one null-prototype global per
* namespace, each declared name an own enumerable async function that bridges over the port
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
*
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
* @param pending - the id-keyed map each posted call parks its handles in.
@@ -256,17 +240,11 @@ export function makeNamespaces(
}
/**
* Run one program to settlement and post the {@link DoneMessage}: wires the
* reply handler, materializes the namespaces and console shim, compiles the
* type-stripped body as an async function (top-level `await`/`return`
* work), and reports a thrown program error as the done message's `error`
* field. Exactly one done message is ever posted.
* @param port - the message port to the host (the real `parentPort`, or the tests' fake).
* Run one program and post its terminal {@link DoneMessage}.
* @param port - host message port or test double.
* @param data - the boot payload the host sent.
* @param streams - the stream objects whose `write` is captured (the real
* `process.stdout`/`process.stderr` in the worker; fakes in tests).
* @returns resolves after the done message is posted (the tests await it;
* the real entry lets the worker exit naturally).
* @param streams - stdout/stderr objects captured as program logs.
* @returns after posting the done message.
*/
export async function runWorkerMain(
port: BootstrapPort,
@@ -1,14 +1,7 @@
/**
* Worker-thread implementation of the code-execution seam: one fresh Node
* worker per run, executing the model's TypeScript after a host-side
* type-strip, with bindings bridged over the message port. Containment, not
* a security boundary (bash-equivalent trust — see the Code Mode RFC's
* trust-posture section): the worker gets an EMPTY environment, a heap cap,
* and two independent budgets — `computeMs` metered on the worker's
* measured event-loop busy time (a hot loop cannot hide behind a pending
* binding call) and a never-pausing `maxWallMs` ceiling — all funneling
* into `worker.terminate()`, which ends hot synchronous loops too.
*
* Worker-thread implementation of the code-execution seam: one fresh Node worker per run,
* executing the model's TypeScript after a host-side type-strip, with bindings bridged over
* the message port.
* @module @deepseek-ai/dsh-code-runtime-worker
*/
@@ -278,11 +271,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
// Model code gets NO ambient environment — stronger than the scrubbed
// env the defensive-patterns rule requires for spawned commands.
env: {},
// Hermetic flags too: without this the worker inherits the host
// process's execArgv (a test runner's or tsx's loader hooks), which a
// bare isolate with an empty environment cannot satisfy. The entry
// needs nothing beyond native type stripping, on this repo's whole
// Node range.
// Hermetic flags too: without this the worker inherits the host process's execArgv (a
// test runner's or tsx's loader hooks), which a bare isolate with an empty environment
// cannot satisfy.
execArgv: [],
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
// Backstop capture: the bootstrap patches JS-level writes into its own
@@ -298,12 +289,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
// whatever the path: honest port entries, FORGED port entries (model
// code posting `log` messages directly, bypassing the worker-side
// LogBuffer), and stray pipe bytes. On the first overflow it emits the
// same in-band marker the worker's LogBuffer would and drops the rest,
// so the documented cap is one shared `maxLogBytes` however it is hit.
// one host-side ledger for everything that lands in `logs`/`strayLogs`, whatever the
// path: honest port entries, FORGED port entries (model code posting `log` messages
// directly, bypassing the worker-side LogBuffer), and stray pipe bytes.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
@@ -327,11 +315,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
// Settlement: exactly one outcome wins; every path funnels through
// here, cleans up the timers/listeners, terminates the worker, and
// resolves only after the worker actually exited (quiescence). Logs
// streamed eagerly before the settlement are kept — a timed-out or
// killed program still shows the model what it printed.
// Settlement: exactly one outcome wins; every path funnels through here, cleans up the
// timers/listeners, terminates the worker, and resolves only after the worker actually
// exited (quiescence).
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
@@ -349,11 +335,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
// Re-cap the completion value HOST-side: the honest path already
// capped it in the worker (prepareValue there), but a forged done
// message bypasses the bootstrap entirely — without this, model code
// could flood the host past maxValueBytes. Honest values pass
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
// Re-cap the completion value HOST-side: the honest path already capped it in the
// worker (prepareValue there), but a forged done message bypasses the bootstrap
// entirely — without this, model code could flood the host past maxValueBytes.
finish({
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
@@ -1,11 +1,5 @@
/**
* Wire protocol between the host runtime and the worker bootstrap. Everything
* crossing the message port is structured-clone-plain and versionless — both
* ends ship in this package, always at the same version. The host treats
* inbound traffic as HOSTILE (the worker runs model code, which can reach
* `parentPort` via `import('node:worker_threads')` and forge any of these
* shapes); the worker treats inbound traffic as trusted.
*
* Wire protocol between the host runtime and the worker bootstrap.
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/
@@ -1,12 +1,6 @@
/**
* The worker-thread entrypoint: self-executing glue over
* `bootstrap.ts`'s {@link runWorkerMain}, kept to the spawn wiring alone.
* Like `bin.ts` CLI entrypoints, this file executes only inside a spawned
* worker isolate — a place the coverage provider cannot observe — so it is
* excluded from the coverage gate while every line of actual logic lives in
* `bootstrap.ts`, unit-tested in-process; the real spawn path is pinned by
* the integration tests that run genuine workers.
*
* The worker-thread entrypoint: self-executing glue over `bootstrap.ts`'s {@link
* runWorkerMain}, kept to the spawn wiring alone.
* @module @deepseek-ai/dsh-code-runtime-worker/src/worker
*/
@@ -5,19 +5,10 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published package (the real-load-path guard
* from docs/testing.md): the unit suite runs `src/` under vitest, where the
* worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js`
* under plain `node`, where it must resolve the sibling `lib/worker.js`
* bundle instead. This spawns plain `node` (NOT tsx) from inside the package
* directory and imports the package BY NAME, so resolution flows through the
* real `exports` map exactly as it would from a downstream install; the
* program exercises the type-strip, the worker spawn, the binding bridge,
* and log capture end-to-end through the built bundles.
*
* It build-gates: SKIPS when the built artifacts are absent (suite run
* without `pnpm run build`); CI runs it after the build step. KEYLESS — no
* model is involved.
* Built-ARTIFACT smoke for the published package (the real-load-path guard from
* docs/testing.md): the unit suite runs `src/` under vitest, where the worker entry resolves
* to `src/worker.ts` — a consumer runs `lib/index.js` under plain `node`, where it must
* resolve the sibling `lib/worker.js` bundle instead.
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
@@ -255,10 +255,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
const { runtime } = await setup({ maxLogBytes: 4 })
const result = await runtime.run({
// The bootstrap patches the stream instance's own `write`; going
// through the prototype's slot reaches the real pipe underneath, so
// the bytes arrive host-side as stray data. The pauses keep the two
// writes in separate pipe chunks and let them land before settlement.
// The bootstrap patches the stream instance's own `write`; going through the prototype's
// slot reaches the real pipe underneath, so the bytes arrive host-side as stray data.
program: `
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('abcd');
@@ -1,15 +1,10 @@
import { defineConfig } from 'tsdown'
/**
* Package-shape override (see the root tsdown.config.ts): besides the
* default lib/index.js bundle, the worker BOOTSTRAP ships as its own
* sibling entry — `new Worker(new URL('./worker.js', import.meta.url))`
* loads it as a file, so it cannot be part of the index bundle. TWO
* single-entry builds, not one two-entry build: a multi-entry build emits
* the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles
* import, which the package.json `files` whitelist (deliberately exact)
* would omit from the packed artifact — each single-entry build inlines its
* own bootstrap copy instead, keeping every shipped file self-contained.
* Package-shape override (see the root tsdown.config.ts): besides the default lib/index.js
* bundle, the worker BOOTSTRAP ships as its own sibling entry — `new Worker(new
* URL('./worker.js', import.meta.url))` loads it as a file, so it cannot be part of the index
* bundle.
*/
export default defineConfig([
{
@@ -1,18 +1,5 @@
/**
* The code-execution seam (`ctx.codeRuntime`): an abstract service 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. Implementations subclass {@link CodeRuntime} and register
* themselves as the `codeRuntime` service; backends may differ by execution
* substrate (worker thread, separate process, container) and by source
* language, both declared as readonly descriptors. The design and its
* consumer (the tool registry's Code Mode) are specified in the Code Mode RFC
* (docs/rfc/implemented/feature/2026-06-15-code-mode.md).
*
* The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing
* about tools or sessions — it is handed named async functions and a program,
* and everything tool-shaped stays with the consumer.
*
* Code-execution seam for running one model-written program against host bindings.
* @module @deepseek-ai/dsh-code-runtime
*/
@@ -35,26 +22,9 @@ declare module 'cordis' {
}
/**
* Abstract code-execution service. Subclass, implement {@link run} and the
* two descriptors, and load the subclass as a plugin — it registers as
* `ctx.codeRuntime` (one implementation per context; loading a second throws,
* cordis' standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} resolves with an error FIELD for every program outcome —
* parse/transform failures, thrown exceptions, budget expiry, abort,
* substrate death ({@link CodeRunFailure}'s taxonomy). It REJECTS only for
* caller misuse of the seam itself (e.g. a run submitted after disposal).
* - Binding calls bridge to the caller's {@link CodeBindingFunction}s
* verbatim; arguments and resolutions must be structured-cloneable, and the
* runtime treats the program as a hostile peer (arbitrary binding names are
* own properties, malformed traffic is rejected or ignored, never crashes
* the host).
* - Runs are isolated from each other: no state survives from one run to the
* next through the runtime.
* - Disposal reaches quiescence: in-flight runs are terminated AND awaited
* before the service's own teardown completes (no orphan substrate survives
* `fiber.dispose()`).
* Abstract code-execution service. Subclass, implement {@link run} and the two descriptors,
* and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation
* per context; loading a second throws, cordis' standard duplicate-service behavior).
*/
export abstract class CodeRuntime extends Service {
/**
+28 -190
View File
@@ -1,31 +1,6 @@
/**
* `BasicCompactService`: the first implementation of the
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
*
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
* with per-block structural overhead.
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
* to a token budget, compact everything older. The cutoff is snapped forward
* to the next balanced tool-pairing boundary so a compacted region never
* splits a step's tool-call/result pair (an open tail step is never crossed —
* compaction declines and retries once it closes).
* - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled
* via `BlockAssembler` with a fixed condense-the-history system prompt;
* NOT a loop step, so `agent/request` never fires — interception happens
* at `llm/stream` like any other direct call.
* - **Surface mutation** — a single `user/message` replace node carries the
* summary; `compact/*` events are log-only lock + provenance records.
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
* sole token-pressure check.
*
* A different backend (real tokenizer, template summarizer, turn-count
* retention) either subclasses this and overrides the {@link
* BasicCompactService.estimateContentTokens} / {@link
* BasicCompactService.summarize} hooks, or implements the abstract
* {@link CompactService} from scratch.
*
* `BasicCompactService`: the first implementation of the `@deepseek-ai/dsh-compact` seam. It
* owns the entire compaction strategy.
* @module @deepseek-ai/dsh-compact-basic
*/
@@ -54,15 +29,8 @@ const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/**
* The summarization system prompt: instructs the model to condense the
* conversation into a fixed, fully-populated structure rather than freeform
* bullets. The fixed structure guarantees coverage of the things a resuming
* model needs (original intent, pending work, the next step, critical context)
* and is stable across compaction cycles, so a prior checkpoint can be merged
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
* transcript already contains a prior checkpoint, the model consolidates rather
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
* extra log/event machinery — the tag travels on the summary surface node).
* The summarization system prompt: instructs the model to condense the conversation into a
* fixed, fully-populated structure rather than freeform bullets.
*/
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.',
@@ -100,29 +68,13 @@ const SUMMARIZE_SYSTEM_PROMPT = [
`- 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 prepended to the landed summary so a resuming model reads it as a
* checkpoint rather than a fresh user request, and continues the task from it.
* It summarizes an earlier span of the conversation; the messages that follow
* are the continuation. Because region compaction can be invoked manually, a
* surface may hold several checkpoints, so the framing does NOT claim that
* everything after it is recent or verbatim — only that the captured context
* should be built on, not restated.
*/
/** 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 `FinishReason` to the error a SUMMARIZATION must throw, or
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
*
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
* (discard) the real history it summarizes. Raising here keeps the original
* surface intact (the caller appends `compact/end` with the error and the auto
* path proceeds with full history). `stop`/future kinds are accepted.
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or `undefined` for an
* acceptable finish. `FinishReason` is merge-extensible.
*/
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
@@ -164,25 +116,7 @@ export class BasicCompactService extends CompactService {
this.config = resolveConfig(config)
if (this.config.auto) {
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
// an assistant/message and a tool/result per step, so the surface (and the
// derived token count) grows WITHIN a turn. The only moment to rescue a
// turn that alone approaches the window is the next step's pre-step
// checkpoint; gating to a turn's first step would let a runaway turn
// overflow before the next turn's check. The listener owns NO threshold
// logic — compactIfNeeded is the single place that decides whether to
// compact, and its in-progress lock serializes concurrent attempts.
//
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
// mutates the session surface, and the loop derives the request `messages`
// AFTER this fires — so a single derive already reflects the compaction,
// with no double-derive and no need to rewrite an already-assembled
// `messages` array. Firing pre-step (outside any open step) keeps the
// log-only `compact/*` records and the replacement node cleanly outside a
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
// closes — never a half-open step.
// Auto-compaction: delegate to compactIfNeeded before every 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)
@@ -289,27 +223,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Summarize conversation text into content blocks via `ctx.llm.stream()`
* assembled through a `BlockAssembler`. A direct one-shot model call, NOT a
* loop step: it does not run the `agent/request` waterfall (that seam shapes
* the loop's conversation requests); per-call
* interception happens at `llm/stream` like any other direct call. The model
* comes from `BasicCompactConfig.summarizationModel`, falling back to the
* agent's own model.
* Override in a subclass for a template or remote summarizer.
*
* Honors the adapter failure contract: an adapter may report a model failure
* by throwing from `stream()` (propagated here) OR by ending the stream with
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
* provider error never yields an empty summary.
*
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
* down the in-flight summarization rather than orphaning the model call.
*
* Returns the summary blocks TOGETHER with the call envelope it actually
* used (`model`, `maxTokens`) — the caller logs the envelope on the
* `compact/summary` provenance event, so an overriding subclass (template
* or remote summarizer) reports its own envelope honestly.
* Summarize conversation text into content blocks via `ctx.llm.stream()` assembled through a
* `BlockAssembler`.
*
* @param text - plain-text rendering of the conversation region to condense.
* @param agent - supplies the fallback model and the session id stamped on
@@ -359,42 +274,10 @@ export class BasicCompactService extends CompactService {
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the NEXT request's pressure — the
* session prefix + the surface-derived history + the system prompt
* ({@link estimatePressure}) — and if it exceeds the threshold
* (`contextWindow * thresholdRatio`), compact
* the oldest surface nodes outside the `retainTokens` budget. The auto-
* compaction listener delegates here rather than pre-checking, so this is the
* only place the decision lives. The prefix counts because every request
* carries it in front of the history (`EpochHeader.messagePrefix`) even
* though it is not derived history — omitting it would under-estimate by
* exactly the prefix and let a deployment at the window edge skip
* compaction, then ship an over-window request. The loop composes the
* prefix BEFORE the pre-step seam and hands it through, so the gate sees
* this instance's actual prefix (never a previous instance's logged one —
* a resumed/forked instance whose contributor grew is gated on the grown
* value from its very first step). Compaction itself can only
* shrink HISTORY: a prefix that alone approaches the window is a
* configuration error no compactor fixes.
*
* Retention is a UNIFORM tail→head walk over the whole surface — turn
* boundaries play NO role. Walking node-by-node from the tail and summing
* token estimates, once the retained total reaches `retainTokens` the cutoff
* is rounded to a balanced tool-pairing boundary: if the cut before the
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
* it is mid-step), the walk continues head-ward until the cut is balanced so
* the whole step is retained (never splitting a step's tool-calls from their
* results); if it stopped on a free node (a node belonging to no step), that
* cut is already balanced. This always rounds toward retaining MORE (retained
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
* pass.
*
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
* auto-compaction re-consolidates any prior head checkpoint into one fresh
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
* surface fits the retain budget, or when no balanced cutoff exists in the
* compactable range (its only content is an open tail step — retry once it
* closes).
* The sole token-pressure gate: estimate the NEXT request's pressure — the session prefix +
* the surface-derived history + the system prompt ({@link estimatePressure}) — and if it
* exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest surface nodes
* outside the `retainTokens` budget.
*/
override async compactIfNeeded(
agent: Agent,
@@ -450,13 +333,7 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve the range by surface POSITION, not numeric seq interval. A prior
// replace lands a fresh high-seq summary node AT the shadowed range's
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
// Resolve the range by surface POSITION, not numeric seq interval.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
@@ -466,14 +343,8 @@ export class BasicCompactService extends CompactService {
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
}
// The region must never split a step's assistant-message tool-calls from
// their tool/results (which would orphan one side and produce a transcript
// every provider rejects). A region is safe iff BOTH its edges are balanced
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
// to no step (pre-step user message, inter-step steering, injection context)
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
// leaves the cut after it unbalanced (the open tool-call has no result yet),
// so it is rejected. See dsh-session's tool-pairing balance check.
// The region must never split a step's assistant-message tool-calls from their tool/results
// (which would orphan one side and produce a transcript every provider rejects).
const events = session.events
if (!isToolPairingBalanced(nodes, events, start)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
@@ -490,13 +361,8 @@ export class BasicCompactService extends CompactService {
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. Auto-compaction satisfies this — it runs on the
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
// strictly inside the open turn (but outside any step). A manual call on a
// fully-closed session has no turn to enclose the events, so reject rather
// than emit an un-enclosed run.
// 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')
@@ -537,13 +403,8 @@ export class BasicCompactService extends CompactService {
...maxTokens !== undefined ? { maxTokens } : {},
})
// --- Surface replacement ---
// The user/message directly shadows all compacted surface nodes with a
// single replace op. It is the ONLY surface event in the compaction
// sequence — compact/start, compact/summary, and compact/end are log-only
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
// the compact/summary provenance event above holds the raw model output.
// --- 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' },
@@ -597,17 +458,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Whether a compaction is currently in progress for `session` — an unmatched
* `compact/start` (no later `compact/end`) WITHIN the current turn.
*
* The scan is scoped to the current turn: walking back from the tail it stops
* at the first `turn/end` (the boundary closing the prior turn). A
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
* persistence repair then closes with a synthetic `turn/end`; scoping here so
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
* compaction's `compact/start` is always in the still-open current turn,
* before any `turn/end`, so it is still detected.
* 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
@@ -650,14 +502,10 @@ export class BasicCompactService extends CompactService {
// 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. A node that belongs to no step is already a
// balanced (free) boundary. Decline if no balanced cut exists at or below
// `keepFromIdx` (the compactable range is only an un-splittable open tail
// step — retry once it closes).
// 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 (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
@@ -673,17 +521,7 @@ export class BasicCompactService extends CompactService {
return { start: firstSeq, end: cutoffSeq }
}
/**
* Keep ONLY text blocks from the model-produced summary before storing it.
*
* The summary lands on the surface as a synthesized `user/message` (see
* {@link _frameSummary}), so the only block type that is both useful and safe
* there is `text`. A model assistant message can otherwise carry `reasoning`
* (private chain-of-thought, must not leak into the durable checkpoint) and
* `tool-call` blocks — and a surviving `tool-call` in a user message would be
* an orphaned call with no matching `tool-result`, exactly the tool-pairing
* breakage compaction works to avoid. Filtering to text drops both.
*/
/** 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')
}
@@ -48,13 +48,6 @@ export type ResolvedConfig = Required<BasicCompactConfig>
/**
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
*
* Convergence is not a static config invariant: provider generation caps can be
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
* of unpredictable size. The backend instead enforces convergence dynamically:
* each committed summary must be smaller than the content it shadows, and
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
* throwing if the surface still exceeds the threshold.
*
* @param config - the raw, unresolved backend config.
* @returns the validated config with `auto` and `charsPerToken` defaulted.
*/
@@ -211,12 +211,7 @@ function expectNoOrphanToolResults(messages: Message[]): void {
describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => {
it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => {
// 3 turns, each one step = { assistant(tool-call), tool/result }. Surface
// (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 —
// 10/20/10 tokens. The tail→head walk retains by whole units; the compacted
// region always ends on a step boundary, so no step's tool-call is split
// from its result. retainTokens=55 keeps the recent tail; the older steps
// compact intact.
// 3 turns, each one step = { assistant(tool-call), tool/result }.
const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 })
const session = toolTurnSession(3)
@@ -231,12 +226,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
})
it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => {
// The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over
// threshold (by the derived role overhead), the tail→head walk stops with the
// retained boundary at the tool/result — which is NOT a step-aligned start (its
// issuing assistant precedes it in the same step). Rounding head-ward to find a
// clean boundary reaches index 0, so there is no step-aligned cutoff in the
// compactable range: compactIfNeeded declines rather than splitting the step.
// The surface is exactly one step: [assistant(tool-call), tool/result].
const s = new Session(SessionId('one-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
@@ -605,27 +595,14 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
// threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40
// for the retention walk), but the derived estimate adds 4 role tokens per
// message → 56 ≥ 48, so the threshold check passes and the walk runs. The
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
// so keepFromIdx reaches 0 and compaction declines.
// threshold = floor(480*0.1) = 48.
const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 })
const session = multiTurnSession(2, 1)
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
// The REGRESSION that motivated dropping turn-protection. A single in-flight
// (open) turn has grown past the threshold on its own: several CLOSED steps,
// each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so
// the turn's OWN early closed steps are eligible — they compact while the
// recent tail stays verbatim, and the harness survives.
//
// On the OLD layer-2 code this test FAILS: the entire open turn was retained
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
// returned null and shadowedSeqs would be empty — the runaway turn could
// never compact and the next model call would overflow the window.
// The Regression that motivated dropping turn-protection.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = new Session(SessionId('runaway'))
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
@@ -665,12 +642,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
// After the first compaction lands a replacement summary node at the head,
// a second compaction (still over threshold) re-consolidates it with newer
// context — head-anchoring means the prior checkpoint is always re-included,
// never stranded. retainTokens=25 leaves a couple of retained nodes after
// the first compaction (so the surface is [summary, …retained], not just
// [summary]).
// Head-anchored recompaction must include the previous summary and retained context.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
@@ -776,10 +748,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
})
it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => {
// A crash mid-compaction left a compact/start with no compact/end; the turn
// it lived in was later closed (persistence repair appends turn/end). A
// whole-log scan would treat that stale start as an active lock forever. The
// scan is scoped to the current turn, so a NEW turn compacts normally.
// A crash mid-compaction left a compact/start with no compact/end; the turn it lived in was
// later closed (persistence repair appends turn/end).
const svc = createTestService()
const s = new Session(SessionId('stale-lock'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -860,11 +830,8 @@ describe('BasicCompactService HMR safety', () => {
})
it('disposing the plugin fiber unregisters ctx.compact', async () => {
// Mount through the real plugin fiber (the Loader path), then dispose it and
// confirm the service registration is torn down. LlmService is mounted first
// so the service's `inject: ['llm']` resolves and the fiber activates. (The
// sibling-fiber ctx.llm resolution this same setup also exercises is covered
// under the "llm inject (real plugin-load path)" suite.)
// Mount through the real plugin fiber (the Loader path), then dispose it and confirm the
// service registration is torn down.
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
@@ -1259,11 +1226,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => {
const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model')
// The summarize call is a direct one-shot model call, not a loop step: it
// does not run agent/request (that seam shapes the loop's conversation
// requests). llm/stream is its interception surface, and a hand-built
// request is not frozen, so mutate-then-next model routing works — the
// adapter resolves AFTER the waterfall, so the rewrite picks the adapter.
// One-shot summaries use llm/stream, not the loop's agent/request seam.
ctx.on('llm/stream', (options, next) => {
options.model = 'routed-model'
return next()
@@ -1526,10 +1489,7 @@ describe('BasicCompactService edge cases', () => {
s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('step/end', { turn: 1, step: 1 })
// Step 2: a tool exchange whose tool/result has empty content → empty
// extraction → skipped. The assistant carries the matching tool-call so the
// surface stays tool-pairing balanced; its text extracts to the tool-call
// placeholder (the one surviving line).
// Step 2: a tool exchange whose tool/result has empty content → empty extraction → skipped.
s.append('step/start', { turn: 1, step: 2 })
s.append('assistant/message', {
turn: 1, step: 2,
@@ -1594,11 +1554,8 @@ describe('BasicCompactService edge cases', () => {
describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => {
it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => {
// A replace inserts the new summary node (a high seq) AT the shadowed
// range's surface position, so the surface becomes
// [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a
// range whose start node has a HIGHER seq than its end node must still
// succeed — the range is positional, not a numeric seq interval.
// A replace inserts the new summary node (a high seq) AT the shadowed range's surface
// position, so the surface becomes [highSeqSummary, …olderRetainedLowerSeqs].
const svc = createTestService({ auto: false })
const session = multiTurnSession(4, 1)
@@ -1606,19 +1563,13 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
const nodes0 = session.surface.nodes
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
// head is the user/message replace node, appended after the compact/summary
// provenance event, so its seq is at least first.summarySeq.)
// The summary node now sits at the head with a seq HIGHER than the retained older nodes
// that follow it — the non-monotonic surface.
const nodes1 = session.surface.nodes
expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq)
expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq)
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
// seq (the head summary node) is GREATER than the end seq (an older retained
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
// The end must land on a step boundary (turn-2's assistant message closes
// its step).
// Second compaction: shadow [summary(head) … turn-2's step end].
const startSeq = nodes1[0]!.seq
const endSeq = nodes1[2]!.seq
expect(startSeq).toBeGreaterThan(endSeq)
@@ -1661,10 +1612,8 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
describe('BasicCompactService llm inject (real plugin-load path)', () => {
it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => {
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a
// sibling LlmService when this service is mounted as its own plugin fiber.
// Asserting the declaration (and exercising the real mount below) guards the
// resolution that root-ctx unit tests cannot, since they share one fiber.
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a sibling
// LlmService when this service is mounted as its own plugin fiber.
expect(BasicCompactService.inject).toContain('llm')
})
@@ -14,24 +14,9 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
* free surface boundary (it carries no tool-call/result pair), so it must be a
* valid region edge on BOTH sides. A surface-anchored balance check sees that;
* the abandoned log-position scan did not.
*
* The loop fires the compaction seam mid-flight, so the landed checkpoint
* `user/message{replace}` sits at a HIGH log seq positioned beside the current
* step even though its SURFACE position is the head. A log-position forward scan
* from the checkpoint reaches the step's own later `assistant/message` and
* wrongly reports the checkpoint as mid-step refusing it as a region end. A
* SECOND compaction that re-summarizes just that head checkpoint (region end ==
* checkpoint) therefore throws and is swallowed, so the surface never
* re-consolidates.
*
* This drives a real auto-compaction through the agent-loop and asserts the
* landed checkpoint balances on both sides AND that re-compacting it (end ==
* checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
* is decided from surface tool-pairing balance.
* CBR-001 regression: a compaction checkpoint that the real loop lands is a free surface
* boundary (it carries no tool-call/result pair), so it must be a valid region edge on BOTH
* sides.
*/
const TOKENS_PER_BLOCK = 10
@@ -132,14 +117,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
)
expect(checkpoints.length).toBeGreaterThan(0)
// The loop fired compaction mid-flight, so each landed checkpoint sits at a
// high log seq beside the step it landed in, even though its SURFACE
// position is the head of the range it shadowed. A checkpoint carries no
// tool-call/result pair (only summarized prose), so every checkpoint still
// on the surface must be a balanced cut on BOTH sides — the cut before it
// (region START) and the cut after it (region END). The abandoned
// log-position scan reported the END as mis-aligned because the forward log
// scan reached the neighbouring step's assistant/message.
// The loop fired compaction mid-flight, so each landed checkpoint sits at a high log seq
// beside the step it landed in, even though its surface position is the head of the range
// it shadowed.
const nodes = agent.session.surface.nodes
for (const cp of checkpoints) {
const node = nodes.find(n => n.seq === cp.seq)
+16 -101
View File
@@ -1,23 +1,7 @@
/**
* The compaction service seam (`ctx.compact`): an abstract service defining
* WHAT compaction does decide when to compact, summarize a range of
* conversation history into a single surface node without saying HOW.
*
* Implementations subclass {@link CompactService}, implement
* {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
* and load as a plugin registering as `ctx.compact` (one implementation per
* context). A tokenizer-, template-, or model-backed implementation can live
* as a sibling package; callers stay on the same `ctx.compact` seam without
* touching consumers.
*
* The split follows the capability-seams RFC interface (this) /
* implementation (deferred) / consumer (a `/compact` tool, deferred) modeled
* on the bash trio. Unlike `dsh-bash`, this interface necessarily
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
* a `Session` and its output is the `ContentBlock` vocabulary. 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).
*
* The compaction service seam (`ctx.compact`): an abstract service defining what compaction
* does decide when to compact, summarize a range of conversation history into a single
* surface node without saying how.
* @module @deepseek-ai/dsh-compact
*/
@@ -42,25 +26,9 @@ declare module 'cordis' {
}
/**
* Abstract compaction service. Subclass implement the two abstract methods,
* and load the subclass as a plugin it registers as `ctx.compact` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Both core methods are abstract: the contract states WHAT compaction does,
* while the entire strategy token estimation, retention policy, event
* sequencing, summarization is a HOW decision owned by the implementation.
*
* Implementations MUST honor:
* - **Surface contract**: a successful compaction shadows the compacted surface
* nodes with a SINGLE replacement node carrying the summary. Because
* `SurfaceEventType` is a closed union, that node is a `user/message` with
* `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
* log-only (lock + provenance).
* - **Blocking**: no compaction begins while another is in progress for the
* same session. The recommended mechanism is the log-recorded lock append
* `compact/start` before the slow work and `compact/end` after (even on
* failure) so the lock is visible to replay and crash recovery.
* Abstract compaction service. Subclass implement the two abstract methods, and load the
* subclass as a plugin it registers as `ctx.compact` (one implementation per context;
* loading a second throws, which is cordis' standard duplicate-service behavior).
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -70,42 +38,11 @@ export abstract class CompactService extends Service {
/**
* Check token pressure and compact if the conversation is too large.
*
* Estimates the NEXT request's size the session prefix, the
* surface-derived history, and the system prompt and if it exceeds the
* backend's threshold, compacts an older range
* via {@link compactRegion}, keeping recent context intact. Returns `null`
* when no compaction is needed.
*
* Scope and guarantees a backend MUST honor:
* - **Compaction acts on surface-derived history only**, but the ESTIMATE
* counts everything the request carries: the loop composes the session
* prefix before the pre-step seam fires and hands it here, so the gate
* sees the prefix this instance will actually send (`EpochHeader.messagePrefix`
* request-only, never derived history). Non-surface context injected
* downstream (into the request `messages` by a later listener) is out of
* this accounting by construction.
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
* checkpoint is
* re-summarized into one fresh checkpoint (the surface holds at most one
* auto-generated checkpoint, always at the head). It is best-effort over
* CLOSED steps: when the only compactable content left is an un-splittable
* open tail step, it declines (`null`) and retries once that step closes.
* - **Single-unit overflow is out of scope.** If a single retained unit (one
* closed step, or a large free node such as a pasted `user/message`) ALONE
* exceeds the budget, compaction cannot help and the call may go out
* over-budget. Bounding an individual unit's size is a separate concern
* as is a session prefix that alone approaches the window (a
* configuration error no compactor fixes: compaction cannot shrink the
* prefix).
*
* @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 signal - cancellation signal. A backend summarizing via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @param sessionPrefix - the instance's composed session prefix, counted toward the
* estimate.
* @param signal - cancellation signal.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
@@ -118,35 +55,13 @@ export abstract class CompactService extends Service {
/**
* Forcibly compact a range of surface nodes into a single summary node.
*
* `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
* summarizes their content and appends a replacement surface node. Used by the
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
*
* The region MUST NOT split a step's `assistant/message` tool-calls from their
* `tool/result`s, leaving the rehydrated transcript with a dangling tool-call
* or an orphaned tool-result that every provider rejects. A region is safe iff
* both its edges are balanced cuts on the surface: the cut before `start` and
* the cut after `end` each have no unanswered tool-call before them. A node
* that belongs to no step (a pre-step user message, inter-step steering, or an
* injection context message) is a balanced (free) boundary; an `end` inside an
* open (unclosed) tail step is invalid its tool-calls have no results yet.
* `dsh-session` exports `isToolPairingBalanced` for this check.
*
* @param session - the session whose surface is mutated.
* @param start - inclusive seq of the first surface node to compact.
* @param end - inclusive seq of the last surface node to compact.
* @param agent - agent context used by router-aware summarizers.
* @param signal - optional cancellation signal. A backend that summarizes via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @throws if compaction is already in progress, if `start`/`end` are not
* valid surface nodes, if `start` is positioned after `end` on the surface
* (the range is a surface-POSITION span, not a numeric seq interval a
* prior replace can leave the surface non-monotonic in seq order), or if
* either boundary is not a balanced tool-pairing cut (would split a step's
* tool-call/result pair).
* @returns what the compaction did (the replaced range and its summary node).
* @param session - session to mutate.
* @param start - first surface seq, inclusive.
* @param end - last surface seq, inclusive.
* @param agent - summarizer context.
* @param signal - optional cancellation.
* @throws when compaction is active or the range is invalid or unbalanced.
* @returns the replaced range and summary.
*/
abstract compactRegion(
session: Session,
+5 -31
View File
@@ -1,16 +1,7 @@
/**
* Plain-text transcript rendering over session events: the shared projection
* used wherever a compaction-class consumer needs "what a model once saw" as
* readable text a summarizer's input, or a recall tool's output.
*
* Extracted from the basic backend's private helpers so the summarize path and
* the recall read path render one span identically (two renderers would drift,
* and a recall reader would then see a different transcript than the one the
* summary was written from). Both functions are pure over their arguments: no
* session access beyond the provided events, no clock, no randomness a
* rendered span is a pure function of the log, so replay reproduces it
* byte-identically.
*
* Plain-text transcript rendering over session events: the shared projection used wherever a
* compaction-class consumer needs "what a model once saw" as readable text a summarizer's
* input, or a recall tool's output.
* @module @deepseek-ai/dsh-compact/render
*/
@@ -18,14 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Render content blocks to a single plain-text string. Text and reasoning
* contribute their text (reasoning wrapped as `[reasoning: …]`); every other
* block type contributes a type-tagged placeholder (`[tool-call: name(args)]`,
* `[tool-result: …]`, ) so the reader is told what non-text content existed
* rather than silently losing it. A `tool-result` block recurses into its
* nested content (`[tool-result: <inner rendering>]`), falling back to a bare
* `[tool-result]` when the nested content renders to nothing. Blocks join
* with newlines; empty-text blocks contribute nothing.
* Render content blocks to a single plain-text string.
*
* @param blocks - the content blocks to render.
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
@@ -59,17 +43,7 @@ export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
}
/**
* Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:`
* transcript. Walks `seqs` in the order given callers pass surface order
* (e.g. a `compactRegion` slice of the surface-node list), which after a
* `replace` is NOT ascending log-seq order (a high-seq summary node can sit at
* the head of the surface before older retained lower-seq nodes); a log-order
* scan would render the transcript out of order.
*
* Only the five surface (message-producing) event types render; a seq naming
* any other event type contributes nothing. `SessionEventMap` is
* merge-extensible, so unknown types are simply non-message events with no
* renderable text.
* Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:` transcript.
*
* @param events - the session log the seqs index into (`session.events`).
* @param seqs - the surface-node seqs to render, in surface order.
-12
View File
@@ -1,17 +1,5 @@
/**
* Compaction vocabulary: the result type and the `compact/*` session events.
*
* Extends {@link SessionEventMap} with `compact/*` event types via declaration
* merging. {@link SurfaceEventType} is deliberately NOT extended `compact/*`
* events are log-only markers (lock + provenance); only the five
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
* performed by a separate `user/message` event carrying the summary (see the
* [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
*
* Configuration lives in the backend, not here: the contract states WHAT
* compaction produces, while every tunable (context window, thresholds,
* retention budget) is a HOW decision owned by the implementation.
*
* @module @deepseek-ai/dsh-compact/types
*/
@@ -261,13 +261,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void',
summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.',
summary: 'Awaited checkpoint for surface mutation before `step/start` snapshots request history.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
summary: 'Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
},
{
name: 'agent/queued',
@@ -285,7 +285,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
},
{
name: 'agent/session-start',
@@ -429,19 +429,19 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
summary: 'Waterfall after a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
},
{
name: 'tools/pre-execute',
mode: 'waterfall',
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
summary: 'Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
},
{
name: 'tools/result',
mode: 'parallel',
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
summary: 'Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
},
{
name: 'workflow/agent-end',
+3 -11
View File
@@ -1,15 +1,7 @@
/**
* Runtime mirror of the cordis `FiberState` const enum plus human-readable
* labels, shared by the mount lifecycle (state reporting) and the inspect
* renderers (plugin-list and mount-table labels).
*
* Cordis exposes `FiberState` as a `const enum`: there is no runtime object for
* Node's type-stripping runner to import, so the members are mirrored here as
* values each typed (via the type-only import) as the cordis enum member it
* mirrors, so enum-typed reads like `fiber.state` compare against them under a
* shared enum type. Source of truth: vendor/cordis/src/fiber.ts (pinned; drift
* only happens through a deliberate vendor sync).
*
* Runtime mirror of the cordis `FiberState` const enum plus human-readable labels, shared by
* the mount lifecycle (state reporting) and the inspect renderers (plugin-list and mount-table
* labels).
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
*/
+18 -97
View File
@@ -1,50 +1,9 @@
/**
* The registration boundary between sandboxed mount code and the real runtime:
* SchemaSpec normalization + validation with teaching errors, the
* marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
* SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
* real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
* The registration boundary between sandboxed mount code and the real runtime: SchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
* return values with.
*
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
* exactly four things register a tool, listen to an event, provide a service,
* call an injected service (timers included) so the façade exposes only those
* verbs and the injected services, each object-valued service individually
* wrapped (a primitive provided value passes through as-is see
* {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, ) is
* DENIED with a teaching error rather than passed through. This closes an
* entire escape class at once: a pass-through proxy that only special-cased
* `ctx.tools` still handed back the raw context through `ctx.root`,
* `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
* `ctx.root.tools.register({…})` to bypass the marker check and host-realm
* normalization a raw vm-realm result then errors a real agent turn at the
* session-log plainness check. The whitelist has no such hole: there is no
* context-valued member to reach, and any injected-service method that returns
* a `Context` is rejected (harness services never do see {@link denyContext}).
*
* Two realm facts drive the tool path. Objects built inside the vm carry the vm
* realm's `Object.prototype`, and the session log's append-time plainness check
* (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
* foreign-realm data so every dynamic tool's `execute` return is JSON
* round-tripped into the host realm and shape-checked against the two
* `ToolExecuteReturn` forms before it reaches the registry (the registry
* trusts the shape blindly it spreads `result.content`, so an unvalidated
* `{ content: 'ok' }` would enter the session log as `['o','k']` and silently
* corrupt the next model request), and the schema itself is rebuilt as fresh
* host-realm objects. And a malformed tool
* schema must fail at REGISTRATION, not when a later request assembles it so
* dynamic tool registration accepts only definitions produced by the sandbox's
* `harness.defineTool`, which normalizes `parameters` up front.
*
* Normalize, don't lecture, where the input has exactly one meaning: models
* write the JSON-Schema dialect by strong prior (the `{ type: 'object',
* properties, required: [] }` wrapper, `type: 'integer'`, `required: false`),
* and each rejection costs a model turn so those convert to the SchemaSpec
* DSL silently, and only genuinely meaningless input (an unknown type, a
* non-boolean `required`) is rejected, with the error enumerating the valid
* vocabulary.
*
* @module @deepseek-ai/dsh-tool-cordis/guard
*/
@@ -195,14 +154,11 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
}
/**
* The `harness.defineTool` handed into the sandbox: the real DSL, with
* `parameters` normalized into a fresh host-realm SchemaSpec (JSON-Schema
* wrapper unwrapped, `integer` mapped, `required: false` dropped) and the
* tool's `execute` return normalized into the host realm via a JSON round-trip
* (see the module doc). The round-trip projects the return onto exactly what
* the log would durably store, and {@link assertExecuteReturn} then vets that
* projection so a non-JSON-serializable OR wrong-shape return surfaces as
* that one call's teaching error instead of poisoning the turn.
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
* into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped,
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
* via a JSON round-trip (see the module doc).
*
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
@@ -236,15 +192,9 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise<
}
/**
* The verbs a mounted plugin may reach through the sandbox `ctx` façade,
* beyond its injected services. `on`/`once` observe events, `provide` exposes
* a service to other mounts, and the timer helpers schedule work each a
* fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
* is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
* mixin accessors that throw `without inject` when read on a plugin that did
* not inject `timer`, so the façade reads `ctx[verb]` only at call time the
* plugin that never touches a timer never trips that, and one that does gets
* cordis's own inject error at the call site.
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the
* timer helpers schedule work each a fiber effect that unwinds on unmount.
*/
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
@@ -258,11 +208,7 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
* name/description/parameters view as `schemas()`, and nothing invocable.
*/
function sandboxTools(ctx: Context): Record<string, unknown> {
// Reads resolve through the MOUNT's own scope (`scopeOf(ctx)`), mirroring
// where the façade's `register` lands its writes (the calling context's
// layer): mount code always sees the tools its own world sees — the global
// view for today's global mounts, its agent's view if a mount ever runs
// under an agent scope.
// Resolve reads and writes through the mount's own scope.
return {
register: (tool: unknown): (() => Promise<void> | void) => sandboxRegisterTool(ctx, tool),
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
@@ -320,15 +266,7 @@ function declaredInjects(ctx: Context): Set<string> {
}
/**
* The sandbox context façade handed to a mounted plugin's `apply` in place of
* the real `ctx`. A whitelist (see the module doc): the registration/eventing
* verbs, the timer helpers, a guarded `tools`, and injected services resolved
* through a guarded `get` / property access. A service is reachable only if the
* plugin DECLARED it in `inject` an undeclared service is denied even when a
* global provider exists, so cordis's activation/unload semantics (park the
* mount when a declared provider goes away) actually bind. Every
* framework-plumbing member is denied with a teaching error; there is no
* context-valued member to reach.
* The sandbox context façade handed to a mounted plugin's `apply` in place of the real `ctx`.
*/
function sandboxContext(ctx: Context): Context {
const tools = sandboxTools(ctx)
@@ -348,16 +286,8 @@ function sandboxContext(ctx: Context): Context {
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
)
}
// Read a service for either access path (property or `get`). `tools` is the
// façade's own surface. An UNDECLARED name is denied with the teaching
// error; a DECLARED one resolves to the guarded service. A declared inject
// is required in cordis (the fiber only activates once every declared
// service is live), so at `apply`/`execute` time `ctx.get(name)` is present
// for a declared name — no undefined case to handle here. `provide()`
// accepts ANY value though (cross-mount composition advertises
// `ctx.provide('name', value)`), so a primitive or null value passes
// through unwrapped: Proxy throws on a non-object target, and only an
// object can carry a method that hands back a Context.
// Read a service for either access path (property or `get`). `tools` is the façade's own
// surface.
const readService = (name: string): unknown => {
if (name === 'tools') return tools
if (!declared.has(name)) return denyRead(name)
@@ -408,20 +338,11 @@ export function isPlugin(value: unknown): value is Plugin {
}
/**
* Wrap a plugin so its `apply` receives the sandbox context façade instead of
* the real `ctx` (see {@link sandboxContext} and the module doc). Both
* function-form and object-form plugins go through the same wrap; the plugin's
* own `inject` declaration is preserved (cordis reads it from the plugin
* object, and pending/active gating happens on the real fiber before `apply`
* runs), so cross-mount provide/inject works unmodified.
*
* `ctx.effect(customCleanup)` is deliberately absent from the façade for now
* `on` / `provide` / `tools.register` cover every mount seen so far, and each
* is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
* once a real mount needs a bespoke disposer.
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
* @param plugin - the plugin the mount code returned.
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
*/
// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup.
export function guardedPlugin(plugin: Plugin): Plugin {
if (typeof plugin === 'function') {
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
+3 -36
View File
@@ -1,37 +1,6 @@
/**
* The self-referential cordis toolset: three model-facing tools that let the
* agent inspect and MODIFY the live cordis runtime it is running inside.
*
* - `cordis_inspect` read-only: provided services, the flat plugin list
* with lifecycle states, registered tools, the dynamic mounts, and the
* catalog-backed `api` / `events` references.
* - `cordis_mount` evaluate model-written code in a `node:vm` sandbox; the
* code returns a cordis plugin, which is mounted as a child of a dedicated
* `cordis-dynamic` group fiber and tracked under an id (`dyn-1`, `dyn-2`, ).
* - `cordis_unmount` dispose one dynamic mount by id, awaiting quiescence.
*
* Everything the model's plugin registers (listeners via `ctx.on`, tools via
* `harness.registerTool`, services via `ctx.provide`) is an effect on the
* dynamic fiber, so unmounting or disposing this plugin itself (HMR) cleans
* it all up through the ordinary cordis lifecycle. The group fiber exists
* exactly so the dynamic mounts form ONE subtree, disposed as a unit with
* this plugin. Design home:
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
*
* The vm sandbox guards against ACCIDENTAL global pollution only, and the `ctx`
* a mounted plugin's `apply` receives is a WHITELIST façade (register a tool,
* observe events, provide/consume services, use timers framework internals
* withheld; see the guard module). Neither is a security boundary: the verbs
* the façade DOES expose reach the real runtime unsandboxed (a mounted tool can
* shell out through `ctx.bash`), so a deployment loads this plugin as
* deliberately as it grants a bash tool. Design home:
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
*
* Plugin export shape: named exports, NO default. The cordis Loader's
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
* collapse the module to the bare `apply` and drop `inject`, crashing at load
* (see docs/postmortem/0001).
*
* The self-referential cordis toolset: three model-facing tools that let the agent inspect and
* MODIFY the live cordis runtime it is running inside.
* @module @deepseek-ai/dsh-tool-cordis
*/
@@ -75,9 +44,7 @@ type ResolvedConfig = Required<Config>
*/
export function apply(ctx: Context, config: Config): void {
const { vmTimeoutMs } = config as ResolvedConfig
// The one group fiber every dynamic mount hangs under. Mounted here (a child
// of this plugin's fiber) so disposing tool-cordis cascades over the whole
// dynamic subtree — the ordinary parent→child fiber lifecycle, nothing extra.
// The one group fiber every dynamic mount hangs under.
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
const mounts = new Map<string, DynamicMount>()
+7 -16
View File
@@ -1,11 +1,7 @@
/**
* 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. Every renderer is a pure function of the runtime handles
* it receives no session state, no clock so inspect output is exactly the
* runtime it describes.
*
* 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.
* @module @deepseek-ai/dsh-tool-cordis/inspect
*/
@@ -131,16 +127,11 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
}
/**
* The `api` section: the generated service catalog intersected with the LIVE
* runtime catalogued live services render summary + method signatures, live
* services without a catalog entry (e.g. ones another mount provides) render
* name + owning fiber, catalog services that are not running are listed
* tersely, the type shapes the live signatures reference follow, and the
* inherited `ctx` surface closes the section.
* Render the generated service catalog against the live runtime.
* @param ctx - the runtime to intersect the catalog with.
* @param api - the service catalog (the generated one by default; injectable for tests).
* @param inherited - the inherited `ctx` surface lines (generated by default; injectable for tests).
* @param types - the type-shape catalog (generated by default; injectable for tests).
* @param api - generated service entries, replaceable in tests.
* @param inherited - inherited `ctx` entries, replaceable in tests.
* @param types - public type shapes, replaceable in tests.
* @returns the section lines.
*/
export function describeApi(
+2 -5
View File
@@ -21,11 +21,8 @@ export interface DynamicMount {
}
/**
* Mount a plugin under the group fiber and settle it. The group fiber loads
* asynchronously right after the owning plugin's `apply`, so it is awaited
* before hanging a child off its context. The child fiber's `await()` settles
* its lifecycle work and rethrows a startup error (e.g. a throwing `apply`);
* on error the fiber is disposed first a failed mount never lingers.
* Mount a plugin under the group fiber and settle it.
*
* @param group - the `cordis-dynamic` group fiber every mount hangs under.
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
+10 -34
View File
@@ -1,20 +1,8 @@
/**
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
* globals are a tagged write-through console, the `harness` registration
* helpers, the encoding primitives a bare vm context lacks, and callable traps
* over the Node APIs the sandbox deliberately withholds. Capability access is
* routed through cordis services, never Node built-ins: filesystem work goes
* through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`,
* timers through the `ctx.timer` helpers (fiber effects, unwound on unmount)
* so a well-behaved mount stays inspectable and disposable. That routing is
* STEERING toward the cordis services, not containment: the sandbox guards
* against ACCIDENTAL global pollution, and it is not a security boundary. The
* host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are
* reachable functions, so a mount that goes looking e.g. through such a
* helper's `.constructor` can still reach the host realm; that is accepted,
* because the `ctx` a mounted plugin's `apply` later receives is the real,
* fully privileged runtime handle, and that is the point of the toolset.
*
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
* withholds.
* @module @deepseek-ai/dsh-tool-cordis/sandbox
*/
@@ -35,17 +23,8 @@ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | '
}
/**
* Per-sandbox prelude: give the vm realm's own constructors a
* `Symbol.hasInstance` that checks BOTH realms. Model code runs against a
* fresh vm realm, but most objects it touches are HOST-realm (the `args` a
* tool's `execute` receives, event payloads a listener observes, service
* return values), so a plain `x instanceof Array` / `instanceof Object` in
* sandbox code would silently be false. The patch replaces each vm
* constructor's own `[Symbol.hasInstance]` with "ordinary check against the
* vm constructor OR the host counterpart" the ordinary algorithm is a pure
* prototype-chain walk, so calling it with the host constructor as receiver
* needs no host-side change. ONLY vm-realm globals are modified; host
* intrinsics are passed in as values and never touched.
* Per-sandbox prelude: give the vm realm's own constructors a `Symbol.hasInstance` that checks
* BOTH realms.
*/
const DUAL_REALM_INSTANCEOF_PRELUDE = `
(hostIntrinsics) => {
@@ -156,13 +135,10 @@ export function syntaxErrorContext(error: Error): string {
}
/**
* Evaluate mount code as the body of an async function inside the sandbox.
* `vmTimeoutMs` only bounds the SYNCHRONOUS portion; an async body escapes it
* acceptable under the module's trust stance. A parse failure is answered
* with the offending line + caret and a teaching hint: TypeScript syntax on
* the failing line gets the remove-annotations fix, anything else gets the
* function-body/bracket-balance reminder (models habitually close the returned
* plugin object with `});` as if it were a callback argument).
* Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only
* bounds the SYNCHRONOUS portion; an async body escapes it acceptable under the module's
* trust stance.
*
* @param sandbox - the contextified object from {@link createSandbox}.
* @param code - the model-written function body; must `return` a plugin.
* @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).
@@ -48,12 +48,7 @@ describe('cordis_mount', () => {
})
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
// The model's execute builds its content blocks INSIDE the vm, where
// Object.prototype is a different object — dsh-session's isJsonValue (the
// gate every `tool/result` append runs through) compares prototype
// IDENTITY, so a raw foreign-realm result would error the whole turn the
// first time the self-made tool runs. harness.defineTool round-trips the
// return into host-realm JSON before it reaches the registry.
// Normalize vm-realm results into host JSON before session validation.
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
@@ -94,11 +89,9 @@ describe('cordis_mount', () => {
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
['undefined — a forgotten return', 'return undefined', 'undefined'],
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
// The failure this prevents: the registry trusts the return shape
// (postExecute spreads result.content), so an unvalidated { content: 'ok' }
// would enter the session log as ['o','k'] and silently corrupt the next
// model request. The shape check turns it into THIS call's error instead —
// one well-formed text block the log and the model can digest.
// The failure this prevents: the registry trusts the return shape (postExecute spreads
// result.content), so an unvalidated { content: 'ok' } would enter the session log as
// ['o','k'] and silently corrupt the next model request.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -150,10 +143,8 @@ describe('cordis_mount', () => {
})
it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
// The dialect models write by strong prior: the { type:'object',
// properties, required: […] } wrapper, `type: 'integer'`, and
// `required: false`. All of it has exactly one meaning — normalize instead
// of burning a model turn on a lecture.
// The dialect models write by strong prior: the { type:'object', properties, required: […]
// } wrapper, `type: 'integer'`, and `required: false`.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -535,10 +526,9 @@ describe('cordis_mount', () => {
})
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
// The args a tool's execute receives are HOST-realm objects; without the
// dual-realm Symbol.hasInstance prelude, `args.items instanceof Array` in
// sandbox code is silently false. The patch lives on the vm realm's own
// constructors only — the host realm's must stay pristine.
// The args a tool's execute receives are HOST-realm objects; without the dual-realm
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
// false.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -77,10 +77,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
// A cordis Service instance carries `.ctx` (a real Context), so
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded
// handle. The service wrapper's return-value guard rejects any Context on
// the way back to sandbox code, so the escape never lands. (`systemPrompt`
// is in the setup harness, so the plugin activates and its apply runs.)
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded handle.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -104,10 +101,8 @@ describe('sandbox context façade — escape surface is closed', () => {
})
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
// The return guard's Promise arm only fires for a HOST-realm Promise
// (a vm-realm one is not `instanceof` the host `Promise`). Provide a
// host-realm service from the test, then inject + await it from a mount:
// the resolved value is non-Context data and passes through.
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
// `instanceof` the host `Promise`).
const ctx = await setup()
ctx.plugin({
name: 'host-async-svc',
@@ -194,11 +189,9 @@ describe('sandbox context façade — inject gate on services', () => {
})
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
// The finding's scenario: a consumer registers a tool built on a provider's
// service WITHOUT declaring inject. cordis would then never park the
// consumer when the provider unmounts, leaving a tool that fails only at
// execution. The gate refuses the undeclared access up front, so the
// dependency is always visible to cordis.
// The finding's scenario: a consumer registers a tool built on a provider's service WITHOUT
// declaring inject. cordis would then never park the consumer when the provider unmounts,
// leaving a tool that fails only at execution.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
@@ -231,11 +224,9 @@ describe('sandbox context façade — inject gate on services', () => {
describe('sandbox tools façade — get is a read-only schema view', () => {
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
// The finding: returning the raw ToolDefinition hands mount code the
// tool's execute function, letting it bypass ToolRegistry.execute (and its
// pre/post hooks). get now returns the same name/description/parameters
// view as schemas(), with no execute. Asserted via a self-made tool that
// reports the shape it saw — world-checked, not self-reported.
// The finding: returning the raw ToolDefinition hands mount code the tool's execute
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
// returns the same name/description/parameters view as schemas(), with no execute.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
+7 -59
View File
@@ -1,47 +1,5 @@
/**
* The default executor-less, UI-less agent spine as ONE bundle plugin.
*
* Loads the fixed set of services every harness agent needs `timer`, the LLM
* service, the session store, system-prompt assembly, the tool registry, the
* skill registry plus local skill provider, the agent registry, the dev-mode
* invariants, the model-facing `bash` and `skill` tool schemas, and the concrete `agent-loop` and forwards the loop's `agents`
* list as its OWN config (default `[]`), so each app supplies its own
* pre-created agents.
*
* It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
* bundle, picked by whatever loads it.
* - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) the bundle
* ships the abstract `llm` service + `tool-bash` consumer schema; the leaf
* registers a concrete adapter on `ctx.llm`.
* - the bash EXECUTOR (`bash-local` or a sandboxed impl) the bundle ships
* the `bash` tool consumer; the leaf provides `ctx.bash`.
* - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra
* (a console logger, `hmr`) these are the coupled "front-door cluster" the
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
* - additional SKILL PROVIDERS; the bundle ships the local filesystem provider
* because local skills are default agent behavior, while embedded or remote
* providers remain deployment choices.
*
* This is the interface/implementation/consumer seam at the composition level:
* the bundle owns the shared spine, the leaf owns the backends, the app package
* owns the front door. `timer` is in the spine (common to every front door it
* writes nothing to stdout); the console logger is NOT (it writes to stdout,
* which the ACP bridge reserves for its JSON-RPC channel).
*
* Services register in the root store keyed by their isolate symbol, so a child
* loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the
* leaf's adapter and executor) exactly as a nested `plugin-include` subtree's
* services were before this bundle existed cordis gates every read on
* `inject`, never on load order, so the fixed child set resolves regardless of
* which entry loads first.
*
* Plugin export shape: named `name`/`Config`/`apply`, NO default export the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` function and drop the
* `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in
* the app packages guard this end-to-end.
*
* The default executor-less, UI-less agent spine as one bundle plugin.
* @module @deepseek-ai/dsh-agent-core
*/
@@ -73,16 +31,11 @@ export interface SkillConfig {
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* and `skills` to the skill registry/local provider/tool consumer. Every field
* is optional INPUT here because each owner's schema supplies the default;
* the schema is the INTERSECTION of the owners' own schemas (with registry
* schemas nested under their bundle keys), so validation and defaulting can
* never drift from them.
* Bundle config: each field forwarded verbatim to the child that owns it `agents` to the
* agent loop (an app that pre-creates no agents, like the ACP bridge, simply omits it),
* `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and
* the explicit model-facing tool order), the `tools` object to the tool registry (its
* presentation `mode`), and `skills` to the skill registry/local provider/tool consumer.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -124,12 +77,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
// The forwarded fields are validated + defaulted by this bundle's intersected
// schema before apply runs, so the ?? fallbacks only narrow the
// optional-input TYPES — they mirror the owners' schema defaults, never
// introduce different ones. toolOrder has no owner-supplied default value —
// ABSENT means "lexicographic order" — so it is forwarded conditionally
// rather than via ??.
// Owner schemas resolve defaults; forward toolOrder only when explicitly set.
ctx.plugin(SystemPrompt, {
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
@@ -187,15 +187,7 @@ describe('dsh-agent-core bundle', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export (it mounts children that carry their own), so that
// collapse would NOT crash at load — the plugin would boot but silently lose
// its config schema. This bundle is also never Loader-unwrapped by any smoke
// (the apps import it directly; the mount test namespace-mounts it), so this
// is its ONLY export-shape guard. Assert directly AND through the real
// `unwrapExports` so adding `export default` to src/index.ts fails here.
// A default export would make Loader discard this namespace's plugin metadata.
expect('default' in agentCore).toBe(false)
expect(typeof agentCore.apply).toBe('function')
@@ -1,18 +1,5 @@
/**
* Negative-path tests for the config catalog generator (`scripts/gen-config-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-config-catalog` in CI.
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
* source the way it promises to an unclassifiable package, an undocumented
* config field, a schema key the config type does not declare, or a referenced
* type name that resolves nowhere. These tests drive `collectConfigCatalog()`
* against synthetic fixture packages to prove each guard fires (and that
* well-formed packages classify and extract correctly), mirroring the
* negative tests for gen-cordis-catalog. The spec lives in this package
* because agent-core is the config-composition plugin (its schema is the
* intersection of its children's), the shape the generator's cross-package
* folding exists for.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+26 -83
View File
@@ -155,11 +155,8 @@ export class ReactLoopAgent implements Agent {
private setStatus(status: AgentStatus): void {
if (this._status === status || this._status === 'disposed') return
this._status = status
// Release quiescence waiters on a transition OUT of running BEFORE emitting
// (the disposer handles the disposed transition separately). Settling first
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
// Release quiescence waiters on a transition OUT of running before emitting (the disposer
// handles the disposed transition separately).
if (status !== 'running') this.settleIdleWaiters()
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
@@ -220,25 +217,15 @@ export class ReactLoopAgent implements Agent {
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is OWED no matter what — even
// if a throwing `session/event` listener escapes from the turn/start append
// (Session.append pushes the event BEFORE notifying listeners) or the
// context/message append throws (non-serializable content, throwing
// listener). The finally re-checks the log via isTurnOpen() and closes the
// turn if one was actually opened, so the log never carries a permanently
// open injection turn that would corrupt later turns/replay. (If the
// turn/start append throws BEFORE pushing — non-serializable trigger, which
// can't happen for our fixed trigger — no turn was opened and none is owed.)
// Once turn/start enters the log, a turn/end is OWED no matter what — even if a throwing
// `session/event` listener escapes from the turn/start append (Session.append pushes the
// event before notifying listeners) or the context/message append throws (non-serializable
// content, throwing listener).
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. Contain a throwing
// turn/end listener: Session.append pushes before notifying, so a throw
// here still leaves turn/end in the log (the turn is balanced) — swallow
// it so it neither replaces the original exception nor skips the flush
// decision below. (It surfaces through the flush path is not needed; the
// turn-balance contract is what matters and it holds.)
// Close the turn if turn/start made it into the log.
if (isTurnOpen(this.session)) {
try {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -247,25 +234,12 @@ export class ReactLoopAgent implements Agent {
// so the turn is balanced; the throw is the listener's bug.
}
}
// Decide the durability checkpoint from the LOG, not a flag: a turn was
// recorded iff this turn's turn/start is logged (it may have been closed
// by a throwing-listener turn/end above, which still counts). A
// `turnRecorded` boolean set after append('turn/end') would be skipped by
// a throwing turn/end listener, losing the flush for a balanced in-memory
// turn (crash before the next turn/dispose would drop the idle injection).
// Decide the durability checkpoint from the LOG, not a flag: a turn was recorded iff this
// turn's turn/start is logged (it may have been closed by a throwing-listener turn/end
// above, which still counts).
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
// Checkpoint the one-shot turn for durability, exactly as the loop does at
// every turn/end. The loop is NOT running (we are idle), so nothing else
// will flush this turn. Fire-and-forget with error containment: inject()
// is synchronous, and a persistence backend failing must not throw into
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
// independently, so a slow flush is safe. The task is tracked until it
// settles: driver disposal awaits every pending idle-injection checkpoint
// before unregistering the agent or detaching the session. A flush failure
// is reported via agent/error (step 0 — the idle-injection convention,
// there is no real step) AND the logger, mirroring the loop's post-turn/end
// flush path so plugins monitoring agent/error see idle-injection
// persistence failures too. A throwing agent/error listener is contained.
// Checkpoint the one-shot turn for durability, exactly as the loop does at every
// turn/end.
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) => {
@@ -279,10 +253,8 @@ export class ReactLoopAgent implements Agent {
}
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a
// logger failure in the catch above cannot become an unhandled rejection.
// Teardown uses allSettled for the same reason: a reporting failure must
// not strand ownership.
// Attach the same retirement callback to both settlement arms so even a logger failure
// in the catch above cannot become an unhandled rejection.
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
void flush.then(retire, retire)
}
@@ -291,15 +263,8 @@ export class ReactLoopAgent implements Agent {
cancel(reason?: string): void {
this.assertDriveEnabled('cancel')
// Arm-gate: only mark a cancellation when there is actually work to cancel —
// a running turn, an in-flight step, or queued/steering work. An idle cancel
// with nothing pending is a true no-op; arming the marker then would wrongly
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
// turn-decision points, which an idle parked loop does not reach until woken
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
// the pre-step window (a send() queued but the loop not yet flipped to
// running) has status `idle` with `hasQueued` true, and the marker exists
// precisely to cover it.
// Arm-gate: only mark a cancellation when there is actually work to cancel — a running
// turn, an in-flight step, or queued/steering work.
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
@@ -307,10 +272,8 @@ export class ReactLoopAgent implements Agent {
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
}
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
// Drop all pending queued + steering work (un-started prompts never run; the cancelled
// turn's steering is not re-enqueued).
this.#inbox.clear()
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
@@ -319,29 +282,15 @@ export class ReactLoopAgent implements Agent {
}
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
* promise) `agent/status('disposed')` fires in the disposer BEFORE the
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
* idle AND has no queued work, resolves immediately. Otherwise queues an
* internal waiter (see {@link idleWaiters}) released on the next
* runningidle/disposed transition, resolving on `idle` directly (the turn
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
* both {@link done} and outstanding idle-injection flushes, not through this).
* Resolve once the agent has reached quiescence after settling out of `running`.
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
// could remove a `ctx.on` waiter before the `disposed` transition fires and
// hang the promise. On disposal the disposer settles the waiter AND we chain
// `done` here for true loop-exit quiescence (status flips to disposed before
// the loop unwinds); a plain idle transition resolves directly.
// running→idle/disposed transition), not an effect-scoped `ctx.on` listener: a concurrent
// fiber disposal runs this agent's listener disposers, which could remove a `ctx.on` waiter
// before the `disposed` transition fires and hang the promise.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {
resolve(this._status === 'disposed' ? this.done : undefined)
@@ -370,12 +319,10 @@ export class ReactLoopAgent implements Agent {
isCancelled: () => this.cancelRequested,
cancelReason: () => this.cancelReason,
clearCancel: () => { this.cancelRequested = false },
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
// cancel-skip path drops the about-to-run turn and re-parks without ever
// flipping running→idle, so a waiter registered in the pre-step window
// (status idle, hasQueued was true) would otherwise hang. This emits no
// agent/status, so an ACP agent/status listener never sees a spurious idle
// that would resolve a freshly-queued prompt as cancelled.
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step cancel-skip path
// drops the about-to-run turn and re-parks without ever flipping running→idle, so a
// waiter registered in the pre-step window (status idle, hasQueued was true) would
// otherwise hang.
settleIdle: () => { this.settleIdleWaiters() },
})
// The disposer must be infallible: it runs inside the fiber's LIFO
@@ -404,10 +351,6 @@ export class ReactLoopAgent implements Agent {
// final lifecycle backstop for anything outside those boundaries.
await Promise.allSettled([this.done])
// No new inject() can start after the synchronous disposed transition.
// Loop because settled tasks retire themselves in promise reactions that
// may run beside this continuation; either the set is empty or this waits
// the exact remaining quiescence boundary. allSettled keeps a failure in
// error reporting from skipping the registry/session/scope disposers.
while (this.pendingIdleFlushes.size > 0) {
await Promise.allSettled([...this.pendingIdleFlushes])
}
+34 -130
View File
@@ -42,17 +42,8 @@ export interface Config {
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
* demo can continue a prior conversation without code changes. Requires a
* `dsh-session-persistence` backend; the resume is deferred until that
* service is available (via `ctx.inject`) and the loaded session's events
* seed the live session so history continues.
*
* The schema accepts a plain string at runtime (cordis.yml values are
* untyped); the brand is compile-time only the config format is the
* boundary where an id enters, so the TYPE declares the brand here.
* If set, the config agent RESUMES this persisted session id instead of starting a fresh
* `${id}-session-<uuid>`.
*/
resumeSessionId?: SessionId
})[]
@@ -75,11 +66,9 @@ export class AgentLoop extends Service implements AgentFactory {
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
// zero-cost compile-time cast, so the runtime schema stays string-based and we
// assert the branded view once here — the single schema boundary.
// The schema validates plain strings (cordis.yml config values are untyped at runtime); the
// {@link Config} TYPE declares the branded `id`/`resumeSessionId` because the config format
// is the boundary where an id enters.
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
@@ -94,25 +83,12 @@ export class AgentLoop extends Service implements AgentFactory {
// Provide the agent-creation factory to the registry (effect-scoped: the
// slot is cleared on dispose).
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
// The prompt variables the shipped loop provides, registered once. The
// sections themselves (`harness:identity`, `deployment:persona`) belong to
// dsh-system-prompt — they must survive a swapped loop plugin — but
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
// it assembles with `{ agent }` each step (loop.ts), and the variables
// project the agent's configured model and its session workspace from that
// context. A provider returns undefined when the fact is absent
// (renderPrompt then rejects a persona that claims it — fail loud).
// The prompt variables the shipped loop provides, registered once.
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) {
if (resumeSessionId !== undefined && resumeSessionId !== '') {
// Resume a prior session instead of starting fresh. resume() needs
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
// runs `cb` with a child ctx once the service exists; the child reads
// the persistence and hands it to resumeWith (which uses this.ctx — the
// parent — for sessions/registry, all in AgentLoop's static inject). A
// failed resume is contained + logged: startup must not crash.
// Wait for a late persistence service before resuming the configured session.
ctx.effect(() => {
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
@@ -120,10 +96,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
// Return the EXACT child-fiber disposer. Cordis moves a returned
// effect into this labeled owner's teardown tree by function
// identity; a wrapper would leave the child as a concurrent sibling
// and could discard its async quiescence promise.
// Return the exact child-fiber disposer.
return fiber.dispose
}, `agentLoop.resume(${id})`)
} else {
@@ -133,54 +106,32 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Config-driven create: an agent on a FRESH, non-colliding session id per run
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
* the shared core for the programmatic factory {@link createAgent}.
*
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
* backend is loaded, a fixed id collides on the second run the backend
* refuses to re-create an id whose log already exists on disk (the SessionId
* is the identity). A fresh id means each run is a new session.
*
* TODO(demo): each run starting a brand-new session is fine for demos but is
* NOT real conversation continuity. A production config-driven agent needs a
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id revisit when the
* UI/ACP path owns session selection.
* @param id - the agent id; also seeds the generated session id.
* @param options - loop options (model, limits, ); defaults applied per option.
* @param meta - optional session metadata for the fresh session.
* @returns the running agent, owned by the calling fiber (no handle).
* Create a config-driven agent with a unique session id for this run.
* @param id - agent id and generated-session prefix.
* @param options - loop options.
* @param meta - optional fresh-session metadata.
* @returns running agent owned by the calling fiber.
*/
// TODO(demo): define a production resume-or-create policy for config-driven agents.
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
// The calling fiber owns the prepared session and agent lifecycle.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
* ACP bridge uses this so the client-generated session id becomes the
* live/persisted session id; the in-process FORK subagent backend passes a
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
* Programmatic factory create ({@link AgentFactory}): an agent on a caller-supplied
* `sessionId` (not `${id}-session`), with optional session metadata (validated `cwd`,
* lineage) and an optional `seed` event prefix.
*
* @param options - agent id, caller-supplied session id, optional seed/meta,
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
// Snapshot every caller-owned field before the first async setup boundary.
// The callback itself is an identity capability; all data fields are
// detached so caller mutation cannot drift a reserved/published identity or
// the options the accepted agent observes.
const agentId = options.agentId
const sessionId = options.sessionId
const setup = options.setup
@@ -201,35 +152,17 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
* continue), and starts a fresh agent on it. The live session id is the
* resumed id, NOT `${agentId}-session`.
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the session log +
* metadata via `ctx.sessionPersistence`, reconstructs the live session with the loaded
* events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on it.
* The live session id is the resumed id, not `${agentId}-session`.
*
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
* configured. NOT hard-injected (that would make non-persistent demos pend
* forever) callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
* @param options - the persisted session id to reload, plus agent id/options.
* @returns the handle for the agent resumed on the reconstructed session.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
// `sessionPersistence` (injecting it would pend non-persistent demos
// forever). The `ctx.<name>` property proxy resolves a service by an
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
// own fiber (which lacks the inject) that walk never reaches the sibling
// backend fiber and throws "cannot get property … without inject". Worse,
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
// sidesteps the fiber walk entirely (a store lookup by the global isolate
// key), so resume works from any caller fiber. It is strict by default: a
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
// and we reject below, rather than handing back an unusable handle.
// Read the service through `ctx.get('sessionPersistence')` — a direct global-store lookup
// keyed by the isolate symbol — not `this.ctx.sessionPersistence`.
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
@@ -257,13 +190,7 @@ export class AgentLoop extends Service implements AgentFactory {
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
let observingOwner = true
// Resume must observe its caller from BEFORE persistence I/O begins. The
// full agent lifecycle does not exist until load returns, so without this
// sentinel a never-settling backend outlives owner disposal and holds both
// public identities forever. `this.ctx.effect` retains the traceable caller
// ownership used by startOwned's lifecycle effect. Install it before even
// reserving the ids: an inactive owner cannot leak a reservation if effect
// registration fails.
// Resume must observe its caller from before persistence I/O begins.
const disposeLoadSentinel = this.ctx.effect(() => () => {
if (!observingOwner) return
markOwnerDisposed()
@@ -306,11 +233,8 @@ export class AgentLoop extends Service implements AgentFactory {
}
} finally {
try {
// Manual handoff/removal must not return transactionSettled: awaiting
// that promise from inside this transaction would deadlock it. If the
// owner already triggered cleanup, this idempotent second disposal is a
// no-op and the owner's first cleanup remains parked on the shared
// settlement promise.
// Manual handoff/removal must not return transactionSettled: awaiting that promise from
// inside this transaction would deadlock it.
observingOwner = false
await disposeLoadSentinel()
} finally {
@@ -360,11 +284,9 @@ export class AgentLoop extends Service implements AgentFactory {
publish: (source: SessionStartSource) => void
disposeAgent: () => Promise<void>
} {
// When creation is invoked through an agent scope (subagents), the owner
// agent's disposed status flips synchronously at handle teardown—earlier
// than Cordis reaches nested scope effects. Include that signal in the
// pre-publication liveness check so a same-turn parent dispose cannot race
// an already-fulfilled setup promise into briefly publishing a child.
// When creation is invoked through an agent scope (subagents), the owner agent's disposed
// status flips synchronously at handle teardown—earlier than Cordis reaches nested scope
// effects.
const ownerAgent = this.ctx.agent
const ownerFiber = this.ctx.fiber
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
@@ -458,22 +380,7 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
* handle's `dispose()` runs the composite effect's disposer (see
* {@link start}) which stops the loop, awaits its exit and outstanding
* idle-injection flushes, unregisters the agent, and detaches the session, in
* that order.
* The same composite effect is what a fiber unload disposes, so both teardown
* triggers honor the ordering identically.
*
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
* single-shot (a second call returns immediately because the effect's epoch is
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
* `dispose()` calls would otherwise resolve before the first call's
* loop + flush quiescence boundary completed. Memoizing the promise makes
* every caller observe that SAME boundary, honoring the
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent.
*/
private async startOwned(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
@@ -491,11 +398,8 @@ export class AgentLoop extends Service implements AgentFactory {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}),
])
// Cordis begins a fiber unload synchronously but invokes nested effect
// disposers from its next microtask. Give that already-started unload one
// checkpoint to deactivate this lifecycle before publication; otherwise
// an immediately fulfilled setup continuation can outrun its owner's
// same-turn dispose and briefly publish an already-doomed child.
// Cordis begins a fiber unload synchronously but invokes nested effect disposers from its
// next microtask.
await Promise.resolve()
if (!lifecycle.active()) {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
+95 -379
View File
@@ -51,20 +51,8 @@ function assertContinuationStop(value: unknown): asserts value is ContinuationSt
}
/**
* Map a model-call {@link FinishReason} to the step error it should raise, or
* `undefined` when the step completed normally.
*
* Adapters report provider/transport failures one of two sanctioned ways (see
* the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
* (the only option for adapters that can't throw mid-stream, e.g.
* library-backed ones). This translates the latter into a thrown step error
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
* never as a normal `completed` assistant message.
*
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
* the switch handles the known terminal-failure kinds and treats every other
* kind `stop`, `tool-calls`, `max-tokens`, future additions as success.
* Map a model-call {@link FinishReason} to the step error it should raise, or `undefined` when
* the step completed normally.
*/
function finishError(finish: FinishReason): CodedError | undefined {
switch (finish.kind) {
@@ -93,17 +81,8 @@ function errorData(err: CodedError): { message: string; code?: string } {
}
/**
* The turn-end contribution of a step's *successful* finish, or `undefined`
* when the step finished ordinarily (a plain `completed`).
*
* {@link finishError} has already converted `error`/`aborted` finishes into
* thrown step errors, so the finishes that reach here are `stop`,
* `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
* `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
* hit the output-token ceiling ended the turn cut-short rather than by the
* model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
* the default `completed`. {@link runTurn} applies this with the rule "any
* `max-tokens` step in the turn makes the turn end `max-tokens`".
* The turn-end contribution of a step's *successful* finish, or `undefined` when the step
* finished ordinarily (a plain `completed`).
*/
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
switch (finish.kind) {
@@ -161,66 +140,15 @@ export interface LoopHandle {
}
/**
* The agent loop. One invocation drives one agent for its whole lifetime:
* The agent loop. One invocation drives one agent for its whole lifetime.
*
* ```
* create agent emit agent/session-start(source) once, before turn 1
* forever:
* wait for queued messages (idle)
* TURN (error-contained a throwing plugin ends the turn, never the loop):
* 'turn/start'; each queued msg: waterfall agent/prompt-submit durable turn boundary (no agent/* mirror)
* allow session('user/message') (+ inject additionalContext) | block drop
* every prompt blocked 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering session('steering/message') catches late steering
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) waterfall system-prompt/assemble
* (scope-filtered; scoped sections/tools join); renderPrompt
* (persona section + {{variables}}) IS the full prompt
* prefix ??= waterfall agent/session-prefix once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history (scope-filtered, fused dispatch)
* await events.serial('agent/pre-step', , prefix) surface mutation (compaction) OUTSIDE the step;
* pressure gates see the prefix the request carries
* boundary = session.deriveMessages() the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
* config = waterfall agent/request(config) frozen seed; a returned replacement switches
* session('request/header'|'request/header-delta') the header event this request owes the
* log (initial/resume anchor, delta, fallback)
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
* stream ctx.llm.stream(req) waterfall llm/stream (raw chunks, frozen req)
* session('assistant/chunk')
* msg = waterfall agent/step-result BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() tools/pre-execute (allow/deny/ask)
* dispatch tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext session('context/message')(s)
* drain steering session('steering/message')
* session('step/end') durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ContinuationDecision; default
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* terminal = serial agent/turn-stop stop or abstain; after all ordinary
* continuation and steering folding
* if terminal: discard pending steering and break
* if action==stop: break
* session('turn/end') durable turn boundary (no agent/* mirror)
* await ctx.sessions.flush(session) durability checkpoint (store-owned carrier)
* re-enqueue leftover steering as queued steering is never stranded
* idle (emit agent/status) unless more queued
* ```
* @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).
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
// Per-instance transmission bookkeeping: whether THIS loop instance has
// anchored the log's header fold yet (its first request logs a
// 'initial'/'resume' request/header snapshot). Everything else the request
// needs is read from the session log itself — the loop holds no
// conversation state (the reconstructability RFC).
// Per-instance transmission bookkeeping: whether this loop instance has anchored the log's
// header fold yet (its first request logs a 'initial'/'resume' request/header snapshot).
const transmission = createTransmissionLog()
const { session } = agent
@@ -233,19 +161,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
// idle wait but before we flip to `running`. The cancelled queued/steering
// work is already cleared by `cancel()`. Clear the marker, then:
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued
// prompt as cancelled);
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
// before the loop resumed), the marker was for the cancelled work only —
// fall through and run the new prompt's turn. Do NOT settle waiters here:
// a whenIdle() waiter must wait for that new turn's running→idle, not
// resolve before it runs (the quiescence contract).
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the idle wait but
// before we flip to `running`.
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -256,18 +173,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
handle.setStatus('running')
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
// check above and `runTurn`. Mirror window 1: clear the marker, then
// - if NOTHING new is queued, drop the about-to-run turn and transition
// back to `idle` (`running` was already emitted, so a real idle
// transition balances the status AND settles `whenIdle()` waiters);
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
// cancels then sends), the marker was for the cancelled work only — fall
// through and run the new prompt's turn (status is already `running`), so
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
// it runs. Settling here would resolve quiescence while the replacement
// is still queued and unrun (the same early-resolve race window 1 fixes).
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` SYNCHRONOUSLY, so
// a `running` listener can `cancel()` in the gap between the check above and `runTurn`.
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -276,21 +183,14 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
}
}
// Re-derive the turn number from the log each iteration (do NOT keep a local
// counter): an idle `agent.inject()` can append its own one-shot turn while
// the loop waits above, so the next real turn must continue from whatever
// turn number is actually last in the log — a stale counter would collide.
// Re-derive turn numbers because idle injection can advance the log.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
} catch (error: unknown) {
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
// before turn/start) — no turn/start was appended, so no turn is open and
// none is owed. A session `error` here would land outside any turn (after
// the previous turn/end), where the persistence backend drops it as a
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
// driver survives and moves on.
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard before
// turn/start) — no turn/start was appended, so no turn is open and none is owed.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
try {
@@ -298,21 +198,12 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
}
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
// before the next iteration's idle wait. NOT gated on the idle transition
// below: a `send()` that lands during the cancelled turn's flush window makes
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
// would never fire and the stale marker would wrongly drop that next prompt's
// turn. Resetting per iteration scopes the marker to exactly the turn that was
// cancelled.
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and before the next
// iteration's idle wait.
handle.clearCancel()
// Steering that arrived too late to join an ordinary turn (turn-end
// listeners, flush) becomes queued input so it is never stranded. A
// terminal-stop owner is the deliberate exception: discard the steering
// again after the close + flush window so terminal policy cannot be undone
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
// remain untouched.
// Steering that arrived too late to join an ordinary turn (turn-end listeners, flush)
// becomes queued input so it is never stranded.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
}
@@ -326,10 +217,7 @@ async function runTurn(
): Promise<boolean> {
const { session } = agent
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
// turn/start has not been appended — so it propagates to runLoop's backstop
// untouched. The queued messages are drained here but appended AFTER
// turn/start (below), so every event in the log lives inside a turn.
// --- Pre-turn.
const queued = handle.inbox.drainQueued()
const first = queued[0]
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
@@ -342,29 +230,19 @@ async function runTurn(
let errorReported = false
let terminalStopped = false
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
// are durable session events only — there is no agent/* step emit to mirror
// them (see the agent event-domain rule). A throwing step/end session-event
// listener must not abort finalization and strand the turn open (turn/end
// balance > notifying one bad listener); it is contained and surfaced as a
// turn error below.
// Close the open step exactly once (idempotent via stepOpen).
const closeStep = (): boolean => {
if (!stepOpen) return false
stepOpen = false
// Session.append pushes step/end BEFORE notifying session/event listeners,
// so a throwing listener leaves step/end in the log (balance holds) but
// would otherwise abort finalization. Contain it and surface it as a turn
// error below.
// Preserve step balance even when an event listener throws after append.
let failure: unknown
try {
session.append('step/end', { turn, step })
} catch (error: unknown) {
failure = error
}
// A throwing step/end session-event listener surfaces as a turn error via
// failTurn (idempotent). This prevents a throwing listener from producing a
// silent "completed" turn when the step itself succeeded, AND keeps
// finalization going when closeStep runs from the outer catch.
// A throwing step/end session-event listener surfaces as a turn error via failTurn
// (idempotent).
if (failure !== undefined) {
failTurn(toError(failure))
return true
@@ -372,21 +250,16 @@ async function runTurn(
return false
}
// Record a step/turn failure exactly once: set the error reason (carrying the
// failing `step` — the durable failure lives entirely on turn/end.reason, there
// is no separate session error event) and emit agent/error (contained — trap: a
// throwing agent/error listener must not re-escape and strand the turn).
// Disposal and abort set `reason` directly without calling this (they are not
// failures).
// Record a step/turn failure exactly once: set the error reason (carrying the failing `step`
// — the durable failure lives entirely on turn/end.reason, there is no separate session error
// event) and emit agent/error (contained — trap: a throwing agent/error listener must not
// re-escape and strand the turn).
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// The turn is always still open here: the only failure that can reach
// failTurn once turn/end is appended would be a throwing turn-boundary
// listener, and turn boundaries are durable session events with no agent/*
// mirror to throw. A throwing `turn/end` session-event listener is already
// contained inside closeTurn (append pushes before notifying, so the
// boundary is durable). So set the error reason for closeTurn to append.
// The turn is always still open here: the only failure that can reach failTurn once
// turn/end is appended would be a throwing turn-boundary listener, and turn boundaries are
// durable session events with no agent/* mirror to throw.
reason = { kind: 'error', step, ...errorData(err) }
try {
events.emit('agent/error', turn, step, err)
@@ -396,18 +269,11 @@ async function runTurn(
}
}
// Close the turn. Called exactly once per turn — the normal loop exit and the
// outer catch are mutually exclusive paths, and this never throws (the append
// is contained below), so there is no re-entry to guard against (unlike
// closeStep, which the cancel branches and the outer catch can both reach).
// Turn boundaries are durable session events only — there is no agent/* turn
// emit to mirror them (see the agent event-domain rule).
// Close the turn.
const closeTurn = (): void => {
// Session.append pushes turn/end BEFORE notifying session/event listeners,
// so a throwing listener leaves turn/end in the log (the turn is balanced)
// but would otherwise escape — from the outer catch it would propagate to
// the runLoop backstop. Contain it: the boundary is durable either way, and
// finalization must not abort on a bad listener.
// Session.append pushes turn/end before notifying session/event listeners, so a throwing
// listener leaves turn/end in the log (the turn is balanced) but would otherwise escape —
// from the outer catch it would propagate to the runLoop backstop.
try {
session.append('turn/end', { turn, reason })
} catch (error: unknown) {
@@ -416,16 +282,10 @@ async function runTurn(
}
try {
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
// matter what throws below; the catch + closeTurn guarantee it (the catch
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
// listener — append pushes before notifying — still gets its turn/end).
// --- Turn boundary.
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.
// 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.
// 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.
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`
@@ -439,13 +299,7 @@ async function runTurn(
)
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.
// Log each veto because turn/end cannot represent every blocked prompt.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
@@ -461,11 +315,7 @@ async function runTurn(
}
while (true) {
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
// zero-step turn that ends `rejected`: break BEFORE the first step so the
// boundary stays balanced (turn/start → turn/end) and the block is a
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
// only ever fires on the first iteration.
// A fully blocked batch ends as a balanced zero-step rejected turn.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
@@ -476,56 +326,29 @@ async function runTurn(
// the request.
drainSteering(agent, handle.inbox, turn)
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
// async listener whose effect fires before we block — always has an armed
// abort to cancel against. isDisposed below covers disposal, which does
// NOT set the cancel marker. Cleared on every exit path below.
// The step's AbortController exists before any async pre-step work so a dispose() or
// cancel() — in a synchronous turn-start listener or an async listener whose effect fires
// before we block — always has an armed abort to cancel against. isDisposed below covers
// disposal, which does not set the cancel marker.
const abort = new AbortController()
handle.setAbort(abort)
// Assemble the system prompt for this step. Done HERE (before step/start)
// because the pre-step seam needs it: compaction measures token pressure
// against the system prompt (it counts toward the budget). runStep reuses
// this same assembly for the request, so the prompt is assembled once per
// step. renderPrompt IS the full prompt — the persona is the order-0
// section (owned by dsh-system-prompt) and `{{variable}}`
// interpolation happens in the render, so there is no separate join.
// Assemble the system prompt for this step.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
const fullSystemPrompt = renderPrompt(assembly)
// Interruption landing after assembly: dispose() or cancel() in a
// turn-start listener (or a listener whose promise resolved before the
// await above) arms either handle.isDisposed() or handle.isCancelled().
// The Abort was created first, so any concurrent abort also lands on it.
// Drop the about-to-start step WITHOUT running the seam — no step is open
// yet, so end the turn accordingly (disposed wins for an unambiguous
// reason).
// Interruption landing after assembly: dispose() or cancel() in a turn-start listener (or
// a listener whose promise resolved before the await above) arms either
// handle.isDisposed() or handle.isCancelled().
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
// Compose the session prefix ONCE per loop instance, lazily before the
// instance's first pre-step: request-only messages placed in front of
// the ENTIRE derived history on every request this instance sends. It
// MUST precede the pre-step seam so compaction gates on THIS instance's
// prefix — reading a previous instance's logged prefix would let a
// resumed/forked instance whose contributor grew skip compaction and
// ship an over-window first request. The result is deep-cloned
// (decoupled from listener-held references), deep-frozen, and cached on
// the transmission bookkeeping, so reuse is structural — the prefix
// cannot change mid-session and the provider prefix cache holds by
// construction (resume = a new instance = a recompose, anchored by its
// 'resume' snapshot). The prefix is not session history — the header
// event in runStep is its only durable record
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
// listener chain and the no-listener fallback: a contribution is a
// RETURNED extension of `await next()`, never an in-place push. This
// runs OUTSIDE the step, before the boundary snapshot: a composing
// listener's session append lands before the boundary and joins the
// CURRENT request.
// Compose the session prefix ONCE per loop instance, lazily before the instance's first
// pre-step: request-only messages placed in front of the entire derived history on every
// request this instance sends.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
@@ -533,16 +356,9 @@ async function runTurn(
() => Promise.resolve(emptyPrefix),
)
// Interruption landing during prefix composition: mirror the assembly
// window above — drop the about-to-start step without running the
// seam, and DISCARD the composition instead of caching it. An
// abort-aware listener may have returned a degraded fallback under
// the firing signal; committing it would ship a prefix no request
// ever used (and no header ever logged) on this instance's next real
// request. The next turn recomposes under a live signal — the cache
// only ever holds a fully composed prefix. The cache-hit path needs
// no such check: nothing awaits between the assembly check above and
// the pre-step seam.
// Interruption landing during prefix composition: mirror the assembly window above —
// drop the about-to-start step without running the seam, and DISCARD the composition
// instead of caching it.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -551,19 +367,7 @@ async function runTurn(
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
// step: after `turn/start` (and the prior step's close) but before
// `step/start`, so a compaction's log-only `compact/*` records and its
// replacement node land cleanly outside any step (honest structure that
// crash-safety relies on — a dangling `compact/start` sits before the
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
// veto): each listener completes its surface mutation before the next, so
// concurrent listeners cannot interleave their `session.append`s. A
// throwing listener escapes to the outer catch, which closes the (not-yet-
// open) step as a no-op and ends the turn via failTurn — a broken
// pre-step plugin ends the turn, not the loop. The composed session
// prefix rides along so token-pressure listeners count everything the
// request will actually carry.
// Run compaction between steps so its surface events remain outside step brackets.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
@@ -573,29 +377,20 @@ async function runTurn(
break
}
// The reconstruction boundary (the reconstructability RFC): the request's
// messages are snapshotted HERE, in the same synchronous frame as the
// step/start append directly below — so the snapshot is exactly the
// derivation over the log prefix strictly before step/start's seq.
// Anything appended later — by a step/start session/event listener, an
// agent/request-window inject(), any concurrent task — lands after the
// boundary and joins the NEXT request. An external reconstructor
// recovers these exact messages by folding the surface over
// events[0..stepStartSeq).
// The reconstruction boundary (the reconstructability RFC): the request's messages are
// snapshotted HERE, in the same synchronous frame as the step/start append directly below
// — so the snapshot is exactly the derivation over the log prefix strictly before
// step/start's seq.
const boundaryMessages = session.deriveMessages()
// Mark the step open BEFORE the append: Session.append pushes the event
// to the log before notifying session/event listeners, so a THROWING
// step/start listener leaves step/start in the log. Setting stepOpen first
// means the outer catch's closeStep() then appends the balancing step/end
// (turn stays enclosed) instead of stranding an open step under turn/end.
// Mark the step open before the append: Session.append pushes the event to the log before
// notifying session/event listeners, so a THROWING step/start listener leaves step/start
// in the log.
stepOpen = true
session.append('step/start', { turn, step })
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
// AFTER the step/start append and before `runStep`: drop the step, end the
// turn accordingly. closeStep balances the already-appended step/start.
// Cancel landing in the step-start window: a synchronous `session/event` step/start
// listener can cancel after the step is already open.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -630,13 +425,7 @@ async function runTurn(
break
}
// The successful step's finish reason carries forward: a `max-tokens`
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
// `max-tokens` or `undefined`, so a later ordinary step never resets a
// max-tokens turn back to completed, and a never-truncated turn keeps the
// default `completed`. The disposal/abort/error branches above and the
// continuation-window disposal check below override this — they win.
// Preserve max-tokens once any step reports it.
const stepReason = stepFinishReason(stepOutcome.finish)
if (stepReason) reason = stepReason
@@ -672,10 +461,8 @@ async function runTurn(
// the next iteration's drain records it.
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
// Terminal policy runs only AFTER the extensible continuation waterfall,
// its optional reason, and late steering have all been folded. Unlike the
// waterfall, this serial seam is monotonic: the first stop bail wins, and
// no later listener or steering override can resurrect the turn.
// Terminal policy runs only after the extensible continuation waterfall, its optional
// reason, and late steering have all been folded.
let terminalStop = false
try {
const stop = await events.strictSerial('agent/turn-stop', turn)
@@ -689,19 +476,13 @@ async function runTurn(
}
if (terminalStop) {
terminalStopped = true
// A continuation reason or listener may have queued steering before the
// terminal checkpoint. Discard only steering (never ordinary queued
// prompts) so it cannot become a next step or be re-enqueued as a fresh
// turn by runLoop's late-steering fallback.
// A continuation reason or listener may have queued steering before the terminal
// checkpoint.
handle.inbox.drainSteering()
shouldContinue = false
}
// A cancel that landed during the continuation window — after the step's
// AbortController was cleared (setAbort(undefined)) but before the next
// step starts — has no controller to observe it, so the turn-scoped marker
// ends the turn here. cancel() also cleared the steering FIFO, so the
// override above did not re-arm continuation.
// A turn-scoped marker catches cancellation between step controllers.
if (handle.isCancelled()) {
reason = { kind: 'aborted', reason: handle.cancelReason() }
break
@@ -718,29 +499,10 @@ async function runTurn(
closeTurn()
} catch (error: unknown) {
// Decide whether this turn was ever opened from the LOG, not a flag.
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a throwing listener on the `turn/start` append leaves turn/start in the
// log even though execution never reached the lines after that append.
// Gating on a "turn started" boolean would skip turn/end and leave a
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
// check the log for THIS turn's turn/start: present means a turn/end is owed
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
// so this catch appends turn/end with the disposed/error reason chosen below.
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
// already in a step branch, so running it again is a safe no-op. Absent
// turn/start means the append threw BEFORE its push (a non-serializable
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
// to the runLoop backstop.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
// Choose the close reason. Disposal wins only if no error was already
// reported: a turn disposed mid-step sets reason=disposed in the step-error
// branch (without reporting an error), so preserve disposed rather than
// overwrite it. Otherwise a mid-step throw on a live agent is a real
// failure → failTurn. (errorReported is mutated only inside the failTurn
// closure, which the analyzer can't follow, hence the inline lint-disable.)
// Choose the close reason.
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
reason = { kind: 'disposed' }
} else {
@@ -755,13 +517,8 @@ async function runTurn(
try {
await ctx.sessions.flush(session)
} catch (error: unknown) {
// The turn is already closed (turn/end appended above) and flush must run
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
// for a session `error` event. Appending one here would land it after the
// last turn/end, where the persistence backend treats it as a crash tail
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
// the failure via agent/error + the logger only; persistence keeps the
// buffered events for the next flush/dispose, so nothing is lost.
// The turn is already closed (turn/end appended above) and flush must run after turn/end to
// be a checkpoint — so there is no in-turn position left for a session `error` event.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
try {
@@ -803,27 +560,17 @@ async function runStep(
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const { session, options } = agent
// Seed the call config: the first request of THIS loop instance seeds from
// current AgentOptions — explicit options always win over the logged
// baseline, which is what keeps fork model-overrides and resume-time
// reconfiguration correct. Later steps seed from the log's folded header,
// which by then is exactly what this instance last logged.
// One deep-cloned, frozen seed serves BOTH the listener chain and the
// no-listener fallback: structuredClone decouples it from the session's
// cached header fold (a raw reference would let a delegating listener
// mutate the fold in place and silently skip the delta log), and the freeze
// makes in-place shaping unrepresentable — a switch is a RETURNED
// replacement, which the header event below records.
// Seed the call config: the first request of this loop instance seeds from current
// AgentOptions — explicit options always win over the logged baseline, which is what keeps
// fork model-overrides and resume-time reconfiguration correct.
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 ?? '' }))
// Shape the call config: listeners return a replacement to switch model or
// sampling (the seed is frozen — content shaping is not expressible here;
// model-visible content flows through the log channels). The header event
// below records whatever the request ACTUALLY uses, so a listener's switch
// is a logged, reconstructable fact, never silent drift.
// Shape the call config: listeners return a replacement to switch model or sampling (the seed
// is frozen — content shaping is not expressible here; model-visible content flows through
// the log channels).
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`)
@@ -845,11 +592,9 @@ async function runStep(
})
recordRequestHeader(session, transmission, header)
// Build and freeze: the request is a pure function of (boundary snapshot,
// logged header) — llm/stream listeners and adapters read it, mutation
// throws. sessionId + frozen is the loop-built marker the dev invariant
// keys on. Message order: header.messagePrefix, then the boundary
// snapshot — the reconstruction equation the invariant recomputes.
// Build and freeze: the request is a pure function of (boundary snapshot, logged header) —
// llm/stream listeners and adapters read it, mutation throws. sessionId + frozen is the
// loop-built marker the dev invariant keys on.
const request: GenerateOptions = deepFreeze({
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
@@ -873,23 +618,16 @@ async function runStep(
assembler.push(chunk)
}
// Adapters report provider/transport failures one of two sanctioned ways
// (see the StreamChunk contract in dsh-llm): throw from stream() — already
// handled by the caller's try/catch — OR end the stream with a
// finish-error/aborted chunk. finishError() maps the latter to the step
// error to raise (turn ends error/aborted, not a normal completed message).
// Normalize terminal error chunks into the same failure path as thrown adapter errors.
const stepError = finishError(assembler.finish)
if (stepError) throw stepError
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)))
// Fire the assistant/message when there is content OR usage: a max-tokens
// step can be cut off with empty content but still carry token accounting,
// and assistant/message is the only host for usage (there is no standalone
// usage event). An empty-content assistant/message is skipped by
// deriveMessages(), so hosting usage on it never injects a spurious assistant
// turn into derived history.
// Fire the assistant/message when there is content OR usage: a max-tokens step can be cut
// off with empty content but still carry token accounting, and assistant/message is the
// only host for usage (there is no standalone usage event).
if (message.content.length > 0 || assembler.usage) {
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
// never empty here — pass the provenance unconditionally.
@@ -908,14 +646,7 @@ async function runStep(
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
// Same content-or-usage guard as the max-tokens branch: a step that finishes
// with neither assembled content nor usage (e.g. a bare `stop` finish that
// streamed nothing) records no assistant/message — an empty-content message
// exists only to host usage, and deriveMessages() skips it either way, so
// appending one with no usage would be a pure trace-only row.
//
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
// Do not append an assistant message without content or usage; omit empty provenance too.
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
@@ -928,11 +659,7 @@ async function runStep(
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
// Per-step buffer of `additionalContext` attached by tools/post-execute listeners.
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
@@ -944,12 +671,7 @@ async function runStep(
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
// TODO(pre-tool-input-rewrite): arguments cannot change after their audit and history events are logged.
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
@@ -959,12 +681,10 @@ async function runStep(
})
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
// The correlation id must be the loop's authoritative call.id (the model-transcript id
// that deriveMessages turns into toolCallId), not result.callId — a post-execute
// waterfall listener returning a mismatched id would otherwise orphan the call↔result
// pairing in the next model request.
callId: call.id,
content: result.content,
isError: result.isError,
@@ -1008,13 +728,9 @@ export function lastTurnNumber(session: Session): number {
}
/**
* Whether a turn is currently open in the session log (a `turn/start` with no
* matching later `turn/end`). Decided from the LOG, not agent status: status
* can be `running` while no turn is open (an `agent/status` listener firing
* before `turn/start`, or the post-`turn/end` flush window before status
* returns to idle), so status is not a reliable open-turn signal. Used by
* `inject()` to choose between appending into an open turn vs. wrapping the
* injection in its own one-shot turn (the turn-enclosure RFC).
* Whether a turn is currently open in the session log (a `turn/start` with no matching later
* `turn/end`).
*
* @param session - the session whose log is inspected.
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
*/
+5 -24
View File
@@ -1,12 +1,7 @@
/**
* Per-loop-instance transmission bookkeeping for the reconstructability
* contract: which header event to append before a request so the session log
* always explains the request (the reconstructability RFC). The loop is
* otherwise transmission-stateless the comparison baseline is the log's own
* folded header (`Session.requestHeader()`), so resume and fork need no
* special path: a fresh loop instance simply logs a `'resume'` snapshot on
* its first request and deltas from there.
*
* Per-loop-instance transmission bookkeeping for the reconstructability contract: which header
* event to append before a request so the session log always explains the request (the
* reconstructability RFC).
* @module dsh-agent-loop/request-log
*/
@@ -37,22 +32,8 @@ export function createTransmissionLog(): TransmissionLog {
}
/**
* Append whatever header event this request owes the log, so folding the log
* reproduces the header the request was built under. Exactly one of four
* things happens:
*
* 1. This loop instance has not logged a header yet a full `request/header`
* snapshot anchors the fold: reason `'initial'` when the log has no header
* events at all (a new conversation), `'resume'` when it does (process
* restart, fork seed the boundary itself is a recorded fact, so the
* snapshot is appended even when nothing changed).
* 2. The header equals the folded baseline nothing; the log already
* explains this request.
* 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline
* reproduces the header exactly) a `request/header-delta`.
* 4. It differs and the delta encoding cannot express the change (a pure tool
* reordering) a full snapshot with reason `'fallback'`; deltas are an
* encoding optimization, never a correctness dependency.
* Append whatever header event this request owes the log, so folding the log reproduces the
* header the request was built under. Exactly one of four things happens.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).
+13 -25
View File
@@ -140,10 +140,8 @@ describe('ReactLoopAgent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// 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.
// Non-serializable injected content makes Session.append throw after turn/start was
// recorded.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
@@ -159,10 +157,7 @@ describe('ReactLoopAgent', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
// pushes before notifying, so turn/end is in the log (turn balanced) but the
// throw must NOT skip the durability checkpoint — the flush decision is made
// from the log, not a flag set after the (throwing) append.
// A session/event listener that throws on the synthetic turn/end.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
@@ -202,10 +197,8 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { 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.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
// A non-serializable source makes the turn/start append throw before the event is pushed
// (Session.append validates before push), so NO turn opens.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
@@ -326,10 +319,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// 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 ReactLoopAgent + direct
// internal driver disposer keeps the emit synchronous.
// 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.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -355,11 +347,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// 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. Regression for the round-3 whenIdle finding.
// 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.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
@@ -377,10 +367,8 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// 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.
// The disposer emits agent/status('disposed') before the driver loop unwinds, so whenIdle()
// must chain `done` (true quiescence) on the disposed path.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
+17 -39
View File
@@ -1,12 +1,8 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the
* broad verb it clears queued + steering work, aborts an in-flight step, and
* drops a turn about to start whereas a bare step abort (the loop's private
* `AbortController`) kills only the current step and leaves the queue intact.
* These tests exercise every window where a cancel can land (idle, pre-step,
* mid-step, continuation) and the marker's arm/reset rules that keep a cancel
* from leaking to a later prompt or hanging `whenIdle()`.
*
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb it
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
* and leaves the queue intact.
* @module dsh-agent-loop/tests/cancel
*/
@@ -95,10 +91,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Queue work, then register a whenIdle() waiter while in the pre-step window
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
// The skip path must settle this waiter directly (no running→idle transition
// ever fires), or it would hang forever.
// Queue work, then register a whenIdle() waiter while in the pre-step window (status idle,
// hasQueued true) — it does not take the fast path.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
@@ -234,12 +228,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// The first composition is interrupted mid-waterfall and — like an
// abort-aware listener bailing on a firing signal — contributes nothing.
// Caching that degraded result would silently strip the prefix from every
// later request of this instance; the loop must discard it and recompose
// on the next send, and the SECOND composition's value must be what the
// wire and the header log carry.
// The first composition is interrupted mid-waterfall and — like an abort-aware listener
// bailing on a firing signal — contributes nothing.
const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] }
let compositions = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
@@ -268,10 +258,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A turn/start listener fires right after turn/start is appended, BEFORE any
// AbortController is installed for the step. Cancelling there must still drop
// the step (the turn-scoped marker, not the step AbortController, is what
// catches this) — no model step runs.
// A turn/start listener fires right after turn/start is appended, before any
// AbortController is installed for the step.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
@@ -400,10 +388,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
// listener can cancel in the gap between the loop's pre-step check and
// runTurn. The second check (after the running flip) must drop the turn —
// runTurn would otherwise throw on the now-empty queue.
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running listener can cancel
// in the gap between the loop's pre-step check and runTurn.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -421,11 +407,7 @@ describe('Agent.cancel()', () => {
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// The window-1 early-resolve race has a window-2 twin: a synchronous
// agent/status('running') listener cancels the about-to-run turn AND queues a
// replacement. window 2 must NOT settle waiters (via setStatus('idle')) while
// the replacement is still queued-and-unrun — it must fall through and run it,
// so whenIdle() resolves on the replacement turn's running→idle, not before.
// 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' })
@@ -451,11 +433,8 @@ describe('Agent.cancel()', () => {
})
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel()
// clears A; prompt B is queued BEFORE the loop resumes from the idle wait.
// The window-1 cancel branch must NOT settle the waiter while B is still
// queued-and-unrun — whenIdle() must wait for B's turn to actually run and
// settle (the quiescence contract), not resolve before B's first event.
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
// 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' })
@@ -465,9 +444,8 @@ describe('Agent.cancel()', () => {
agent.cancel('drop A') // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only AFTER B's turn fully ran — by which point B's
// user message and a turn/end are in the log. (Before the fix it resolved
// immediately, with zero events, then B ran afterward.)
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
// and a turn/end are in the log.
await idle
expect(userTexts(agent)).toContain('B')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
@@ -101,9 +101,7 @@ describe('config-driven session id', () => {
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
// Run 2: a CONFIG agent with resumeSessionId continues that session. The
// resume is deferred until sessionPersistence loads (ctx.inject), so wait
// for the agent to appear, then assert it is on the resumed id with history.
// Run 2: a CONFIG agent with resumeSessionId continues that session.
const ctx2 = new Context()
await ctx2.plugin(LlmService)
await ctx2.plugin(SessionStore)
@@ -37,11 +37,7 @@ function send(agent: ReactLoopAgent, text: string) {
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
// A non-serializable message source makes the turn/start append throw BEFORE
// the event is pushed (Session.append validates before push), so turn/start
// never enters the log. runTurn sees no logged turn/start and rethrows; the
// runLoop backstop reports via agent/error (step 0) + the logger and the
// driver survives. This is the ONLY path that reaches the backstop.
// Pre-append validation reports through agent/error without corrupting the log.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+2 -3
View File
@@ -70,9 +70,8 @@ describe('Inbox', () => {
r1()
await p1
// Now enqueue: the first waiter's wakeup (which was overwritten) won't
// fire, and the second waiter's wakeup was cleared by cancel.
// The enqueue calls wakeup?.() but wakeup was cleared — no crash, no hang.
// Now enqueue: the first waiter's wakeup (which was overwritten) won't fire, and the second
// waiter's wakeup was cleared by cancel.
inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
// The overwrite path + finally cleanup are exercised
})
@@ -117,14 +117,9 @@ describe('agent/prompt-submit', () => {
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
// before the single deriveMessages(). So a compaction listener on
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
// otherwise it would measure/compact stale history. This cross-test proves
// the two seams compose in the right order (each is covered in isolation
// elsewhere; this asserts they see each other's effects on the same turn).
// The merge of the interception seams with master's compaction seam pins one ordering:
// `agent/prompt-submit` runs (rewriting the prompt and injecting context) before the step
// loop, and `agent/pre-step` fires inside the step before the single deriveMessages().
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -189,9 +184,7 @@ describe('agent/prompt-submit', () => {
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
// vetoed prompt and its reason would vanish from the log entirely.
// Two prompts queued into one turn: block "secret", allow "safe".
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -618,11 +611,9 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
})
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
// The whole point of the interception taxonomy: a "native hook" needs no
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
// cordis plugin subscribing to the canonical events and returning typed
// decisions. This proves all four seams compose end-to-end through the REAL
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
// The whole point of the interception taxonomy: a "native hook" needs no dsh-hook-protocol,
// no external command, no hook/* log — it is an ordinary cordis plugin subscribing to the
// canonical events and returning typed decisions.
const NativeGuard = {
name: 'native-guard',
apply(ctx: Context) {
+13 -25
View File
@@ -183,11 +183,7 @@ describe('agent loop', () => {
})
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
// A persona claiming {{cwd}} on a session with NO cwd is a deployment
// authoring error — renderPrompt throws, the turn ends with an error, and
// the same agent must then RUN a later turn to completion (not merely
// report idle status): a rescue listener supplies the variable and the
// follow-up prompt reaches the model.
// A missing cwd variable must fail one turn without preventing a later valid turn.
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
@@ -522,9 +518,8 @@ describe('agent loop', () => {
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// A listener appending a surface node in pre-step lands it BEFORE step/start
// in the log — proving the seam fires outside the step. The node is still in
// the derived request for that step (derive happens after step/start).
// A listener appending a surface node in pre-step lands it before step/start in the log —
// proving the seam fires outside the step.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -557,10 +552,9 @@ describe('agent loop', () => {
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
// The seam fires before step/start, so a throw escapes to runTurn's outer
// catch: the not-yet-open step closes as a no-op, the failure surfaces via
// agent/error, and the turn ends `error` (recorded on the durable turn/end).
// The loop survives and a follow-up prompt still runs.
// The seam fires before step/start, so a throw escapes to runTurn's outer catch: the
// not-yet-open step closes as a no-op, the failure surfaces via agent/error, and the turn
// ends `error` (recorded on the durable turn/end).
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -633,10 +627,8 @@ describe('agent loop', () => {
})
it('a max-tokens step earlier in a turn still surfaces as max-tokens after a later completed step', async () => {
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so
// continuation must be FORCED to reach step 2 which finishes normally
// (stop). The rule "any max-tokens step surfaces as max-tokens" means the
// turn ends max-tokens even though the LAST step completed cleanly.
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so continuation
// must be FORCED to reach step 2 which finishes normally (stop).
const adapter = new MockAdapter([
maxTokensResponse('first half'),
textResponse('second half'),
@@ -718,11 +710,8 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// No-data-loss: a max-tokens step whose only content was a dropped tool call
// has EMPTY assistant content, but its usage must still be represented. It
// rides on an (empty-content) assistant/message — there is no standalone
// usage event — and that empty message is skipped by deriveMessages(), so
// the derived history above is NOT corrupted by a spurious assistant turn.
// No-data-loss: a max-tokens step whose only content was a dropped tool call has EMPTY
// assistant content, but its usage must still be represented.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 },
@@ -730,10 +719,9 @@ describe('agent loop', () => {
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk
// has nothing to record: empty content and no accounting → no assistant/message
// (the empty-content host exists only to carry usage). The turn still ends
// max-tokens.
// A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to
// record: empty content and no accounting → no assistant/message (the empty-content host
// exists only to carry usage).
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
@@ -1,12 +1,5 @@
/**
* Property-based tests for the agent loop's inbox/turn scheduling (the
* property-testing RFC). Deterministic by construction: schedules are driven
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
* is a finding, not timing noise.
*
* Invariants: every sent message appears exactly once in the log (none lost);
* turn numbers strictly increase; status transitions follow the legal machine
* idlerunningidle (and disposed at teardown).
* Property-based tests for the agent loop's inbox/turn scheduling (the property-testing RFC).
*/
import { describe, expect, it } from 'vitest'
@@ -146,10 +139,8 @@ describe('agent loop scheduling properties', () => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
// Capture an idle waiter before EACH send; the last one is guaranteed
// to resolve because the final send always triggers (or joins) a turn
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a
// trailing settle step can't cause a hang.
// Capture an idle waiter before EACH send; the last one is guaranteed to resolve
// because the final send always triggers (or joins) a turn that ends idle.
let lastIdle: Promise<void> | undefined
for (const step of steps) {
const idle = nextIdle(ctx, agent)
@@ -9,15 +9,12 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
* With-key proof that log-derived requests translate into REAL provider cache
* hits: a multi-step tool turn (plus a follow-up turn) against the live
* DeepSeek API must report `cacheReadTokens > 0` on every request after the
* first the adapter maps the provider's `prompt_cache_hit_tokens`, and the
* per-step usage recorded on `assistant/message` events is the production
* observable for cache behavior (the reconstructability RFC's measurement
* layer: prefix stability is corollary #1). Mocks prove the requests are
* append-extensions; only the real API proves those bytes actually hit the
* provider cache. Key-gated skips entirely without $DEEPSEEK_API_KEY.
* With-key proof that log-derived requests translate into real provider cache hits: a
* multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
* `cacheReadTokens > 0` on every request after the first the adapter maps the provider's
* `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
* the production observable for cache behavior (the reconstructability RFC's measurement
* layer: prefix stability is corollary #1).
*/
// Long enough that the shared request prefix comfortably spans the provider's
@@ -1,11 +1,8 @@
/**
* Loop-level reconstructability: every request the loop sends is a pure
* function of the session log messages are the derivation at the step/start
* boundary, the header is the fold of request/header* events and every
* request is an append-extension of its predecessor unless a logged event
* (compaction replace, header change) explains the difference. The requests
* recorded by the mock adapter are the observable; the offline-rebuild test
* at the bottom is the theorem stated end-to-end.
* Loop-level reconstructability: every request the loop sends is a pure function of the
* session log messages are the derivation at the step/start boundary, the header is the fold
* of request/header* events and every request is an append-extension of its predecessor
* unless a logged event (compaction replace, header change) explains the difference.
*/
import { describe, expect, it } from 'vitest'
@@ -412,10 +412,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
// — without an explicit flush or clean dispose, the notice must still reach
// disk, since a crash before the next turn would otherwise lose it.
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
@@ -437,10 +434,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn so it is turn-enclosed —
// otherwise scanLog would treat the trailing context as a crash tail and
// drop it on reload (the bug this guards).
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
@@ -114,10 +114,8 @@ describe('HIGH: abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
executed.push('aborter')
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
// Fire the in-flight step's AbortController directly (the loop registers it on the
// agent).
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
@@ -171,21 +169,8 @@ describe('HIGH: steering from late extension points is never stranded', () => {
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
// The /goal pattern steers from a step boundary so the model addresses a
// standing goal before stopping. Step boundaries have no agent/* mirror, so
// the surviving hook point is the durable step/end session event. With a
// no-tools first step the default continuation is stop; the steering queued
// here must force the `!shouldContinue && hasSteering` override so the SAME
// turn runs another step.
//
// The override is what this test guards, so it asserts the same-turn shape —
// NOT merely that the content reaches requests[1]. Without the override the
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
// message, which ALSO lands in requests[1] (just one turn later). So a
// content-only assertion passes with the override disabled and guards
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
// re-enqueue fallback ⇒ TWO turns.
// The /goal pattern steers from a step boundary so the model addresses a standing goal
// before stopping.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
@@ -252,11 +237,9 @@ describe('HIGH: steering from late extension points is never stranded', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
// Abort only the in-flight step, via its AbortController directly — not cancel(), which
// clears the inbox and would drop the queued steering this test proves survives a step
// abort.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
@@ -497,10 +480,9 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => {
describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// The second sanctioned adapter error path (besides throwing): an
// adapter that cannot throw mid-stream ends the stream with a
// finish-error chunk (e.g. the pi-ai adapter mapping a provider 401).
// The loop must NOT log a normal assistant/message + completed turn.
// The second sanctioned adapter error path (besides throwing): an adapter that cannot throw
// mid-stream ends the stream with a finish-error chunk (e.g. the pi-ai adapter mapping a
// provider 401).
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
]
@@ -567,10 +549,8 @@ describe('P1-6: a step/start session-event listener sees the event already in th
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a step/start listener always finds the matching event already in the
// log. (Step boundaries have no agent/* mirror — the session log is the live
// feed.)
// Session.append pushes the event before notifying session/event listeners, so a step/start
// listener always finds the matching event already in the log.
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
@@ -593,10 +573,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
})
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
// Harness with the invariants plugin loaded as an oracle: it throws on
// append if the log goes unbalanced (turn/end while a step is open,
// turn/start while a turn is open, etc.), so a regression surfaces as an
// InvariantError on the NEXT turn's append rather than a silent imbalance.
// Invariants turn latent log imbalance into an immediate test failure.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -628,14 +605,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
// Step boundaries have no agent/* mirror; a throwing step/start session-event
// listener is the surviving step-boundary-listener failure. The loop marks
// the step open BEFORE appending step/start (Session.append pushes before
// notifying, so a post-push listener throw still leaves stepOpen=true), so
// the outer catch's closeStep() appends the balancing step/end — the turn
// stays enclosed. The invariants oracle (balancedHarness) rejects any
// imbalance, so a green run proves turn/start → step/start → step/end →
// turn/end nesting holds.
// Step boundaries have no agent/* mirror; a throwing step/start session-event listener is
// the surviving step-boundary-listener failure.
let threw = false
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
@@ -720,13 +691,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
// (disposal is not a failure). This is the surviving path to that sub-branch
// now that there is no turn-boundary emit to throw from.
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests disposal AND
// throws.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
@@ -763,14 +729,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
// loop must therefore still owe (and append) a turn/end — deciding "owed"
// from the log via isTurnOpen, not a "turn started" flag that the throw
// skipped. Otherwise the turn stays permanently open and poisons the next
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
// oracle — because the throwing listener is itself a session/event
// subscriber.)
// Session.append pushes the event before notifying session/event listeners, so a listener
// throwing on turn/start leaves turn/start IN THE LOG.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
@@ -787,10 +747,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// The error was surfaced exactly once via agent/error.
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
// The turn is BALANCED: turn/start is in the log (it was pushed before the
// listener threw), so a turn/end was owed and appended — no open turn. The
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
// check (no open turn remains).
// The turn is BALANCED: turn/start is in the log (it was pushed before the listener threw),
// so a turn/end was owed and appended — no open turn.
const types = [...agent.session.events].map(e => e.type)
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
@@ -805,11 +763,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step/end listener via failTurn so the
// turn ends with reason error, not a silent "completed" with the throw
// swallowed. Regression test for the closeStep() catch that previously
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
// boundaries have no agent/* mirror; the session-event listener is the path.)
// closeStep() must surface a throwing step/end listener via failTurn so the turn ends with
// reason error, not a silent "completed" with the throw swallowed.
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
@@ -848,13 +803,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
// A finish-error stream opens a step then fails it, driving finalization
// through closeStep() with the step open. closeStep appends step/end; a
// session/event listener throwing on THAT must not abort the catch before
// closeTurn — step/end is already logged (balance holds) and the throw is
// contained + surfaced via failTurn, so turn/end is still appended. (The
// failed step itself also routes through failTurn; the step/end-listener
// throw is the second, contained, failure.)
// A step/end listener failure must not prevent turn/end finalization.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
@@ -884,12 +833,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
// session/event listeners, so a throwing listener leaves turn/end in the log
// (the turn is balanced) but must not escape — from the normal-path closeTurn
// it would otherwise propagate; the append is contained so the loop continues.
// Turn boundaries are durable session events only (no agent/* mirror), so this
// session/event append-notify throw is the sole turn-end-listener failure path.
// closeTurn appends turn/end; Session.append pushes it before notifying session/event
// listeners, so a throwing listener leaves turn/end in the log (the turn is balanced) but
// must not escape — from the normal-path closeTurn it would otherwise propagate; the append
// is contained so the loop continues.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -931,9 +878,6 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
}))
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -966,11 +910,8 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// An empty stream yields zero assistant/chunk events (finish defaults to
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
// the content-or-usage guard fires and an assistant/message is appended. Its
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
// An empty stream yields zero assistant/chunk events (finish defaults to `stop`), so
// chunkSeqs is empty.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
@@ -997,12 +938,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// loop unblocks.
// Block `system-prompt/assemble` on a promise.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
const blocked = new Promise<void>(r => void (releaseAssemble = r))
@@ -1113,9 +1049,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Block the `agent/pre-step` serial seam on a promise we control, then
// dispose the agent's fiber. When the block releases, the loop must see
// isDisposed() at the post-seam check and end the turn disposed.
// Block the `agent/pre-step` serial seam on a promise we control, then dispose the agent's
// fiber.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -364,11 +364,9 @@ describe('agent scope lifecycle', () => {
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
// native-private #carrier — a proxy-receiver carrier made
// `this.send(...)` throw TypeError. The carrier binds methods to the real
// agent, so driving through the event `this` is a working supported shape.
// ds-review-bot regression: agent/* listeners are typed `this: Scoped<Agent>`, and
// ReactLoopAgent's send/steer/cancel read the native-private #carrier — a proxy-receiver
// carrier made `this.send(...)` throw TypeError.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -403,11 +401,9 @@ describe('agent scope lifecycle', () => {
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
// Open a turn so the drain has real work: the loop must finish it BEFORE
// the registry entry goes away (the agent/disposed contract: "its fiber
// and any in-flight turn have been torn down"). Wait for the turn to be
// OPEN in the log — a dispose landing in the pre-step window would drop
// the queued prompt without ever opening a turn.
// Open a turn so the drain has real work: the loop must finish it before the registry entry
// goes away (the agent/disposed contract: "its fiber and any in-flight turn have been torn
// down").
const turnOpen = new Promise<void>((resolve) => {
const off = ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') { off(); resolve() }

Some files were not shown because too many files have changed in this diff Show More