diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 51ddb872c3..01d76ca439 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -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.` (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. diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index de7eda4142..c170a9391b 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 843e09e8b2..643e558c82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-`; 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` 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 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6824c71cc6..fdee984f36 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18ef659ee0..e896fdcf0c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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-`. 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-`. */ 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} (`''`) 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} (`''`) 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 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0c5399b41b..bbb9d738aa 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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): 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): 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, 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`), 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, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | 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, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise @@ -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, 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, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -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`), built by the emitting side via `scopeTarget`/`agentEvents`. +Scope-filtered dispatch: keyed to `agent`. ```ts cordis-catalog 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -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, 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, 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, turn: number, step: number, message: Message, next: () => Promise): Promise ``` 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, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise @@ -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, turn: number): Promise | 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, req: ApprovalRequest, next: () => Promise): Promise @@ -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): Promise @@ -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): 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, 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): Promise | 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, 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, 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, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -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, exec: ToolExecution, next: () => Promise): Promise @@ -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, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise @@ -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, exec: ToolExecution, next: () => Promise): Promise @@ -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, exec: Readonly, result: Readonly): Promise | 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) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ddf2a860e4..25a8e0503b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ async createAgent(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise ``` -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 ``` 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 @@ -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 ``` 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 abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise @@ -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 abstract stat(target: FsTarget, signal?: AbortSignal): Promise @@ -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 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 abstract append(id: SessionId, events: readonly SessionEvent[]): Promise @@ -201,7 +156,7 @@ abstract list(): Promise 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 async assemble(context: AssembleContext = {}): Promise ``` -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 @@ -281,7 +234,7 @@ async execute(exec: ToolExecutionInput): Promise 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 ``` -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) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 3de6b5861e..a885599c17 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1ed2405671..fe07d2ce25 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4bbe894338..7fa9bc00fa 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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 (`:code:`), 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 (`:code:`), 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) diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index b4ee7d83d3..36f45ca40a 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -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). diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 2b1c198812..d36c5bb73d 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.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` 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 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index e6ecc974d7..4895d08c49 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 73967c14b6..51064c9170 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -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. diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md index 4902a0e833..443f24597e 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -57,7 +57,7 @@ export function deadline( export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` is `AbortSignal.any([upstream, ])` 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 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9091965a59..78d4efbcbf 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -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` 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` 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`. 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` 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` 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. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index be18e94ae0..83d3f7629d 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -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 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index bba12727f7..264633f2a1 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -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. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 4c9b89b8d0..2afcce9914 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -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 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index a5f294f76d..246eaeccc7 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -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) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 1257554d30..0b754a5801 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -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. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index e3e740b31c..d1ed071589 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -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: : not found`, `: No such file or directory`, `: 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 ` / `--rw ` 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]`), 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). diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 3ec6c75cbc..00f9fb041c 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -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:] …` 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. diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index fd73a2b6e7..2eaf112878 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -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 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index ea86d1c4eb..14d931a8f3 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -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 diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index b424a253bd..d809376115 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -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 `/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 diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index f4ebe921b5..5ed9c6354e 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index c62d3e9739..2789644283 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, }, diff --git a/examples/AGENTS.md b/examples/AGENTS.md index d54104a189..210b316b65 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -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 diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index e3d8542efd..deebd154bb 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -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 (`/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 `/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 `/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 diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8ff012c2c3..a04742ad82 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -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.` 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') diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..2d63b4b346 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -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 }, ] diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index bdb800186a..df891c9124 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -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' }] }] }, })) diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index 718aa96721..2edcd3dcd2 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -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. diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 854cf49d2a..9a850644a0 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -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: { diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index c6b9f930a8..d8721540a8 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -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. diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 388fcb0058..2bca2eb66b 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -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') diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index d09cdf4728..ba56cac324 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -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. diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 5db02fb6be..73d39c661a 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -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, diff --git a/examples/sandbox-acp-agent/README.md b/examples/sandbox-acp-agent/README.md index bada288bde..dead7d419c 100644 --- a/examples/sandbox-acp-agent/README.md +++ b/examples/sandbox-acp-agent/README.md @@ -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. diff --git a/examples/sandbox-acp-agent/tests/acp.snapshot.ts b/examples/sandbox-acp-agent/tests/acp.snapshot.ts index 418b8068d3..bcf28f105a 100644 --- a/examples/sandbox-acp-agent/tests/acp.snapshot.ts +++ b/examples/sandbox-acp-agent/tests/acp.snapshot.ts @@ -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 }, ] diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index 93915717a1..8317e44317 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -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: [] }) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ac26b926f7..9876af063f 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -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.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` 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.`. 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.` 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. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6903c06e32..0eb9fdaa42 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -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[] = [] 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, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index bc4a017dea..099f1bf1b4 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -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 ` 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 = 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((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) }) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 1d6e93afe3..77bd19ffc4 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -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) diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 090d06b2fe..33bb8eff4c 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -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 { }) 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> = [ { enforcement: 'partial', denialSignatures: ['permission denied'] }, { enforcement: 'full', denialSignatures: ['read-only file system'] }, diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ac7e00c3a3..48f8de73a3 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -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). diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 63c5757175..b184714ad2 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -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() @@ -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. diff --git a/packages/bash/bash/src/session-mode.ts b/packages/bash/bash/src/session-mode.ts index 03ad6e3d7c..79b65cfdb9 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/bash/bash/src/session-mode.ts @@ -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 */ diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 39dbc162c6..b786be62d3 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -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 } diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 800de0132c..083b0c14ca 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 31e6512ae0..7368059d1c 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -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 = { 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 "" 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 "" 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 => { - // 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]' diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 38576b6f28..ab2eaa2b3f 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -56,12 +56,7 @@ async function setup() { */ const fakeAgentDisposers = new Map Promise | 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('allowed-once')) const seen: (string | undefined)[] = [] diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index f2e0d343f3..f4a29504f2 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -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 { @@ -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((done) => { finishResolve = done }) const finish = (result: Omit): 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) } } : {}, diff --git a/packages/code-runtime/code-runtime-worker/src/protocol.ts b/packages/code-runtime/code-runtime-worker/src/protocol.ts index b8ea122c5b..cb75321d4a 100644 --- a/packages/code-runtime/code-runtime-worker/src/protocol.ts +++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts @@ -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 */ diff --git a/packages/code-runtime/code-runtime-worker/src/worker.ts b/packages/code-runtime/code-runtime-worker/src/worker.ts index efaafdb038..12e74310cd 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker.ts @@ -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 */ diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 66ce1830b6..d420790441 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -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)) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index edc2bd1271..2167bdbd20 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -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'); diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 5af39d936a..5e1e5099c5 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -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([ { diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 5595469afe..e06fecc8c9 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -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 { /** diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index c0d4ff483a..bfe4fb927c 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -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 = '' const SUMMARY_CLOSE_TAG = '' /** - * 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 { - // 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 => block.type === 'text') } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index a590c01431..05169286d7 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -48,13 +48,6 @@ export type ResolvedConfig = Required /** * 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. */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index e3d80cd567..d1de5e00b8 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -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') }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f2ca3ccdd0..dd883711b8 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -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) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7d1138314c..c0cff9c083 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -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, diff --git a/packages/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts index 6e977df007..78bc3988bc 100644 --- a/packages/compact/compact/src/render.ts +++ b/packages/compact/compact/src/render.ts @@ -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: ]`), 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. diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index ba9834910f..e13b00f4d9 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -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 */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7f7882f2b7..2e93e3f0a5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -261,13 +261,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/pre-step', mode: 'serial', signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | 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, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - 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, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - 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, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise', - 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, exec: ToolExecution, next: () => Promise): Promise', - 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, exec: Readonly, result: Readonly): Promise | 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', diff --git a/packages/cordis/tool-cordis/src/fiber-state.ts b/packages/cordis/tool-cordis/src/fiber-state.ts index 2b1ee166b7..103c979f0f 100644 --- a/packages/cordis/tool-cordis/src/fiber-state.ts +++ b/packages/cordis/tool-cordis/src/fiber-state.ts @@ -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 */ diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 10c2255141..7091e0c9a1 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -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 { - // 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) => sandboxRegisterTool(ctx, tool), schemas: () => ctx.tools.schemas(scopeOf(ctx)), @@ -320,15 +266,7 @@ function declaredInjects(ctx: Context): Set { } /** - * 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 diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 409123f7c6..a8c75a4251 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -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 */ 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() diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index 260fa4dd14..097ca41fdf 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -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( diff --git a/packages/cordis/tool-cordis/src/mount.ts b/packages/cordis/tool-cordis/src/mount.ts index a222e81da1..a811602a2f 100644 --- a/packages/cordis/tool-cordis/src/mount.ts +++ b/packages/cordis/tool-cordis/src/mount.ts @@ -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`). diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 5ed6b52b50..39de411926 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -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-.js`). diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index fc29eeb2a0..880e7e07b0 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -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: ` diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index d3ade92572..35e5f118fd 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -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: ` diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index d12ef3bad5..f7dacb62ec 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -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 } : {}, diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 855bb28d49..8c29036af4 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -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') diff --git a/packages/core/agent-core/tests/gen-config-catalog.spec.ts b/packages/core/agent-core/tests/gen-config-catalog.spec.ts index 1d0e533ed7..65a6f6b9ff 100644 --- a/packages/core/agent-core/tests/gen-config-catalog.spec.ts +++ b/packages/core/agent-core/tests/gen-config-catalog.spec.ts @@ -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' diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c4c35f34db..5178e9883f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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 - * running→idle/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 { 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((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]) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bd5b7e3188..805ce8e6b8 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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-`. 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-`. */ resumeSessionId?: SessionId })[] @@ -75,11 +66,9 @@ export class AgentLoop extends Service implements AgentFactory { private pendingAgentIds = new Set() private pendingSessionIds = new Set() - // 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-`). 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 = {}): 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 { // 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 { - // 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.` 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() const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() 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 } { - // 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` 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`) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 8c30e42257..d5dce71774 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -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 { - // 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 { 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. */ diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index d2763f5c2a..bab2149ab4 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -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). diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index ab3494d991..7cd4ed28c3 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -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 diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 56376b69a7..bc066e3433 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -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 => { @@ -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) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 07d6cd9e9b..a9a9e56428 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -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) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7a044bfb57..bb81108e22 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -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' }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 79a518ba2b..f893af55db 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -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 }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index d4ec6312ba..ba6a3d8e79 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -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) { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5815217fc4..6aa3aa8604 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -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' }, diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 1e603a1cc4..4b75f87966 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -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 - * idle→running→idle (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 | undefined for (const step of steps) { const idle = nextIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 5ccaa7e797..49805b043b 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -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 diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index bb6d613954..629cc24448 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -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' diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2a4ba7e904..024d26f1f4 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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 diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index e527099a88..e6b9c41b13 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -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(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(r => void (releasePreStep = r)) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2bf58be832..dcc43ae603 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -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`, 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`, 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((resolve) => { const off = ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') { off(); resolve() } diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index d9b0a87e1d..421893d13b 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,10 +1,8 @@ /** - * Loop-level tool-order determinism: the request/header event — and therefore - * the frozen request the adapter receives — carries the assembly's canonical - * tool order (system-prompt's `toolOrder` config, or lexicographic name - * order), regardless of the order tool plugins happened to register in. - * Registration order is a plugin-load artifact (concurrent dynamic imports - * race), so nothing downstream of the registry may depend on it. + * Loop-level tool-order determinism: the request/header event — and therefore the frozen + * request the adapter receives — carries the assembly's canonical tool order (system-prompt's + * `toolOrder` config, or lexicographic name order), regardless of the order tool plugins + * happened to register in. */ import { describe, expect, it } from 'vitest' @@ -93,11 +91,7 @@ describe('loop-level canonical tool order', () => { }) it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => { - // The assemble rejection escapes to runTurn's outer catch: the open turn - // closes with an `error` reason (agent/error mirrors it), no step opens, - // no request/header is logged, the adapter never sees a request, and the - // agent returns to idle — a misconfigured deployment fails every turn - // deterministically instead of silently reordering nothing. + // Unknown tool order fails before step or request creation and returns the agent to idle. const adapter = new MockAdapter([textResponse('never sent')]) const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST]) registerNamed(ctx, 'alpha') diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b02dfeab57..451559d7b7 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,13 +1,5 @@ /** - * Fused scope-carrier dispatch for agent-subject events, plus the assembly - * context builder. The ONE sanctioned spelling for dispatching `agent/*` - * events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the - * scope carrier ({@link scopeTarget} keyed by the agent) AND injects the - * subject as the first event argument in one move, so the correct dispatch is - * also the shortest — a dispatch site cannot pass a carrier keyed to one - * agent while naming another as the subject, which is the invariant the - * dev-mode scoped-dispatch check asserts at runtime. - * + * Fused scope-carrier dispatch for agent-subject events, plus the assembly context builder. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -89,11 +81,7 @@ export interface AgentEventDispatch { */ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const carrier: Scoped = scopeTarget(agent, agent) - // The ordinary dispatch methods forward through Cordis' variadic mixins. The - // fused (carrier, name, agent, ...rest) tuple is provably a valid argument - // list for the matching thisArg overload, but TypeScript cannot relate the - // generic Tail spread back to that overload's conditional parameter - // tuple — hence one contained, shape-preserving cast per method. + // The ordinary dispatch methods forward through Cordis' variadic mixins. return { emit(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function @@ -108,10 +96,8 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { strictSerial(name, ...rest) { return (async (): Promise => { // EventsService.dispatch applies the carrier filter and emits the same - // internal/dispatch instrumentation as ctx.serial, then mutates `args` - // down to the actual listener parameters. Invoke those callbacks in order - // ourselves so every non-undefined value reaches the caller's validator; - // Cordis serial would discard null/false before validation could see them. + // internal/dispatch instrumentation as ctx.serial, then mutates `args` down to the + // actual listener parameters. const args: unknown[] = [carrier, name, agent, ...rest] const callbacks = ctx.events.dispatch('serial', args) for (const callback of callbacks) { diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index c73de89e6d..1b5da5dc7a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -65,18 +65,7 @@ export interface CreateAgentOptions { /** Per-agent options (model, …). */ agentOptions?: AgentOptions /** - * Creation-time composition of the agent's scoped world. The factory awaits - * setup after minting `agentCtx` but BEFORE inserting or announcing either - * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. - * - * **Setup composes, it never drives**: calling `send`/`steer`/`inject` here - * would run an unpublished agent and violate the session-start boundary. - * Drive the agent only after the creation promise resolves. + * Creation-time composition of the agent's scoped world. */ setup?: (agentCtx: Context) => Promise | void } @@ -105,18 +94,8 @@ export interface ResumeAgentOptions { } /** - * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / - * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder - * can tear this agent down. `dispose()` stops the loop, awaits its exit and - * every outstanding idle-injection flush (quiescence — NOT just the `disposed` - * status flip), unregisters the agent, removes its session from the store, and - * finally unwinds its scoped world. This order captures every agent-started - * `session/flush` before the session is detached and keeps scoped listeners - * alive through those checkpoints. - * - * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only - * for the OWNER that created it. Config-created agents (the loop's own startup) - * are owned by the loop fiber and never need a handle. + * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / {@link + * AgentRegistry.resume}. */ export interface AgentHandle { agent: Agent @@ -131,15 +110,8 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, unlocks driving at - * `agent/session-start`, and only then starts the loop. The sequence is - * rollback-covered, but notifications delivered before a later listener - * failure remain observable; if agent announcement began, rollback emits - * `agent/disposed`, while the session entry is removed without a separate - * disposal event. The owner disposes the resolved handle to stop/drain, - * unregister, remove the session, and unwind the scope. + * Create a new agent on a caller-supplied session id. + * * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ @@ -174,12 +146,8 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') - // The `ctx.agent` DX accessor: default `undefined` on every context, so a - // plain plugin context reads cleanly instead of hitting the Cordis - // unknown-property throw. Each Agent.ctx shadows it with an own property - // (own properties resolve before the context proxy is consulted), so the - // accessor body never needs to resolve a scope itself. Effect-scoped: - // unwinds with this service's fiber. + // The `ctx.agent` DX accessor: default `undefined` on every context, so a plain plugin + // context reads cleanly instead of hitting the Cordis unknown-property throw. ctx.accessor('agent', { get: () => undefined }) } @@ -198,10 +166,7 @@ export class AgentRegistry extends Service { this.factory = factory return () => { this.factory = undefined } }, 'agents.setFactory()') - // The exact cordis effect disposer (the agents.register() convention): a - // caller's composite effect can yield it for in-order teardown; the - // loop's constructor effect returns it directly, identity-nesting the - // registration under that effect. + // Return the exact Cordis disposer to preserve teardown nesting. return dispose } @@ -232,22 +197,11 @@ export class AgentRegistry extends Service { } /** - * Register a live agent. Throws if an agent with the same id is already - * registered. Emits `agent/created` on registration and `agent/disposed` - * when the calling fiber is disposed — both with the agent's scope carrier - * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the - * emits are scope-filtered regardless of which context invoked `register` - * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * Register a live agent. + * * @param agent - the already-constructed agent to record in the store. - * @returns the EXACT Cordis effect disposer (single-shot; a repeat call - * returns undefined without awaiting an in-flight teardown). Exact - * identity is load-bearing: a composite (generator) effect that owns a - * teardown ORDER — the agent factory's lifecycle chain — must yield THIS - * function so Cordis nests the unregistration at that yield position; - * yielding a wrapper would leave it disposing as a concurrent sibling on - * owner unload, unregistering the agent (and emitting `agent/disposed`) - * while its final turn is still draining. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined + * without awaiting an in-flight teardown). */ register(agent: Agent): () => Promise | void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { @@ -277,10 +231,8 @@ export class AgentRegistry extends Service { if (!entered) return entered = false this.store.delete(agent.id) - // An insertion rolled back before announce was never externally created, - // so emitting disposed would invent an impossible lifecycle edge. Marking - // happens before the created emit: if a later created listener throws, - // earlier listeners may already have observed it and must see disposal. + // An insertion rolled back before announce was never externally created, so emitting + // disposed would invent an impossible lifecycle edge. if (!this.announced.delete(agent)) return try { this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 4196eb43d6..792921e7ba 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -1,47 +1,7 @@ /** - * Agent interface and event taxonomy. Every plugin programs against the - * `Agent` handle defined here; the concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop`. - * - * Merge-extensible: `AgentOptions` supports declaration merging for - * plugin-specific creation options. - * - * ## Event-domain semantics (the boundary rule) - * - * The harness has three event domains, each with one job: - * - * - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT - * log. Owns `SessionEventMap`; every entry is JSON-only (no live objects). - * One `session/event` emit per append, plus the `session/flush` parallel - * durability checkpoint. Answers "what happened, durably/replayably." A - * consumer that wants the live transcript subscribes here. - * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the - * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ - * `agent/request`/`agent/session-prefix`/`agent/step-result`/ - * `agent/turn-continuation` waterfalls and the serial `agent/pre-step` / - * `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits - * (`agent/status`, `agent/error`, `agent/created`/ - * `agent/disposed`, `agent/queued`, `agent/session-start`) - * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — - * they are durable `session/event` records. Answers "right now, with the agent - * object — intercept or observe." - * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. - * - * **The rule:** a durable, replayable fact is a SessionEvent; a live - * interception or a transient/live-object signal is an `agent`/`tools` Cordis - * event. A turn/step boundary is a durable fact: it lives in the session log - * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` - * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary - * keeps a session-id→agent map from `agent/created`/`agent/disposed`. - * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` - * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. - * - * The interception waterfalls here (`agent/prompt-submit`, `agent/request`, - * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision; - * the terminal serial `agent/turn-stop` returns the stop-only subset. The - * convention is pinned by - * `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`. - * + * Agent interface and event taxonomy. Every plugin programs against the `Agent` handle defined + * here; the concrete implementation lives in `@deepseek-ai/dsh-agent-loop`. + * Scope-filtered dispatch: keyed to `agent`. * @module @deepseek-ai/dsh-agent/types */ @@ -110,54 +70,22 @@ export interface SendOptions { */ export type AgentStatus = 'idle' | 'running' | 'disposed' -/** - * Model-facing context an interception listener wants the agent to SEE on the - * next request — the canonical shape behind every "inject extra context" - * decision ({@link PromptDecision}, {@link PostToolDecision}, - * {@link ContinuationDecision}). It is `agent.inject()`ed as a - * `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()` - * defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin - * context as a user prompt and corrupt derived history. A bridge sets - * `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not - * optional — the label is load-bearing, never defaulted here. - */ +/** Model-facing injected context with an explicit, non-defaulted source. */ export interface HookContext { content: ContentBlock[] source: MessageSource } /** - * The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns - * for ONE drained queued message, before it becomes a `user/message`. Maps onto - * the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`. - * - * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt - * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a - * separate `context/message` the next request also sees. - * - `block` drops the prompt (it never becomes a `user/message`); `reason` is - * the durable record of why. The loop appends a `prompt/blocked` session event - * (carrying the original content, source, and `reason`) in place of the - * dropped `user/message`, so the veto survives replay even in a MIXED batch - * where a sibling prompt is allowed. A batch whose EVERY prompt is blocked - * additionally opens a zero-step turn that ends with {@link TurnEndReason} - * `rejected` (so the boundary stays balanced and a UI can render "blocked by - * hook"). + * The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns for one + * drained queued message, before it becomes a `user/message`. Maps onto the Claude Code + * `UserPromptSubmit` hook's allow/block + `additionalContext`. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; reason: string } -/** - * The decision an {@link Agent} `agent/turn-continuation` waterfall listener - * returns. The loop computes the default (`continue` when the step had tool - * calls or steering was injected, else `stop`); listeners override it to - * force-continue (`/goal`, `/loop`) or force-stop (budget guards). - * - * A `continue` may carry a `reason`: model-facing context recorded as next-STEP - * steering within the SAME turn (the loop enqueues it through the steering - * channel, so the continued turn's next step sees it). This is the typed twin of - * the existing "steer from a step/end listener" `/goal` pattern. - */ +/** Continuation override; a continue reason is recorded as next-step steering. */ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } @@ -212,304 +140,130 @@ export interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * from this synchronous method, but lifecycle disposal awaits it before - * unregistering the agent or detaching its session. A failing flush is - * reported via `agent/error` (step `0`) and the logger, never thrown into the - * caller. - * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. + * Inject in-session context (file-change notices, skill content, cron notifications, …): + * appends a `context/message` session event the next model request sees at its chronological + * position, rendered as tagged synthetic context rather than a user prompt. Does not run the + * model. */ inject(content: ContentBlock[], options?: SendOptions): void /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. + * Cancel ALL pending work for the agent. `cancel()`. */ cancel(reason?: string): void /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. + * Resolve once the agent has reached quiescence after settling out of `running`, or + * immediately if it is already idle with no queued work. */ whenIdle(): Promise - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. + // Subagent backends create ordinary child Agent handles through the subagent seam. } declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- /** - * An agent's fully composed scoped world was published in the - * {@link 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 {@link AgentRegistry}. + * * @param agent - the newly registered agent with its live session and completed setup. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode emit */ 'agent/created'(this: Scoped, agent: Agent): void /** - * 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`. * @param agent - the deregistered agent; its driving handle is now inert. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode emit */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * 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`. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode emit */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A message entered the agent's inbox (queued or steering). `source` is - * the resolved source (defaults applied), not the caller's raw options. + * 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`. * @param agent - the agent whose inbox received the message. * @param content - the enqueued content blocks, verbatim. * @param info - the resolved source plus whether it entered as steering. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void // ---- session lifecycle (emit) ---- /** - * The agent's session lifecycle began, fired once before its first turn. - * `source` says why ({@link 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 + * ({@link SessionStartSource}: fresh startup, a resumed persisted session, …). + * + * Scope-filtered dispatch: keyed to `agent`. * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Dispatch is scoped to `agent`. * @mode emit */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void - // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer - // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ - // `step/end` session events off the `session/event` feed (the session log is - // the live transcript feed). See the module doc's three-domain rule and the - // "remove agent boundary mirror events" RFC. + // Turn and step boundaries are not mirrored as agent/* emits: a consumer that needs them + // reads the durable `turn/start`/`turn/end`/`step/start`/ `step/end` session events off the + // `session/event` feed (the session log is the live transcript feed). // ---- step/request extension seams (serial + waterfall) ---- /** - * 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 - * {@link 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`), 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`. * @param agent - the agent about to open the step. - * @param turn - the already-open turn this step belongs to. - * @param step - the number of the step about to start. + * @param turn - open turn number. + * @param step - upcoming step number. * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. - * @param sessionPrefix - the instance's frozen session prefix, for the same measurement. + * @param sessionPrefix - frozen prefix for the same measurement. * @param signal - aborts in-flight listener work when the turn is torn down. * @mode serial */ - // TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic - // per-step seam — compaction - // is their only consumer, so a wide event carries payloads just one listener - // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy - // prompt provider, or move token-pressure measurement behind a - // compaction-specific seam instead of the shared pre-step checkpoint. + // TODO: move prompt-pressure inputs behind compaction if no second consumer appears. 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void /** - * 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 - * {@link 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`. * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param source - the message's resolved source. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode waterfall */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise /** - * Waterfall: shape the step's call configuration — model switching, - * sampling overrides — by returning a replacement {@link 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 {@link 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 {@link agent/pre-step}. Call `next()` to - * delegate, or return an {@link LlmCallConfig} without it to - * short-circuit. + * Waterfall: shape the step's call configuration — model switching, sampling overrides + * — by returning a replacement {@link LlmCallConfig} (the frozen seed is the config the + * loop would otherwise use). + * + * Scope-filtered dispatch: keyed to `agent`. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. - * @param config - the config the loop would use (frozen); return a replacement to switch. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * @param config - the config the loop would use (frozen); return a replacement to + * switch. * @mode waterfall */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** - * 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 {@link 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch: keyed to `agent`. * @param agent - the agent whose session prefix is being composed. * @param prefix - the frozen empty seed; return an extended replacement to contribute. * @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down. @@ -517,71 +271,49 @@ declare module 'cordis' { */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise /** - * Waterfall: post-process the assembled assistant {@link Message} before - * tool dispatch (validation, content rewriting, …). + * Waterfall: post-process the assembled assistant {@link Message} before tool dispatch + * (validation, content rewriting, …). + * + * Scope-filtered dispatch: keyed to `agent`. * @param agent - the agent that received the step's response. * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode waterfall */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** - * Waterfall: override the turn-continuation decision via a typed - * {@link 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 {@link + * ContinuationDecision}. + * + * Scope-filtered dispatch: keyed to `agent`. * @param agent - the agent deciding whether to run another step. * @param turn - the turn being continued or stopped. * @param defaultDecision - what the loop would do absent an override. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode waterfall */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise /** - * 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`. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Dispatch is scoped to `agent`. * @mode serial */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined // ---- error notifications (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`. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. * @param error - the failure, verbatim. - * 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`), built by the emitting side via - * `scopeTarget`/`agentEvents`. * @mode emit */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 9eac8587a4..856ef899f7 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -1,16 +1,5 @@ /** * Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`). - * - * The generated catalog is frozen by a regenerate-and-diff freshness gate, so - * the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI. - * What a freshness diff CANNOT prove is that the generator REJECTS malformed - * source the way it promises to — a missing `@mode` tag, a tag that - * contradicts the signature shape, or a JSDoc-completeness violation (missing - * prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an - * unannotated return type). These tests drive `collectEvents()` / - * `collectServices()` against synthetic fixture packages to prove each guard - * fires (and that well-formed declarations pass), mirroring the drift-guard - * negative tests for verify-type-equiv. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index b699a72765..09269f8618 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -1,15 +1,5 @@ /** - * Negative-path tests for the export-surface JSDoc gate - * (`scripts/verify-export-jsdoc.ts`). - * - * The gate's positive half runs against the real tree in CI (`pnpm run - * verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that - * the walk REJECTS an undocumented surface the way it promises to — and that - * every deliberate exemption (heritage members, plugin-protocol slots, - * constructors, overload implementations, augmentation bodies, re-exports) - * actually holds. These tests drive `collectExportJsdocViolations()` against - * synthetic fixture packages, mirroring the gen-cordis-catalog negative - * tests. + * Negative-path tests for the export-surface JSDoc gate (`scripts/verify-export-jsdoc.ts`). */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index e57f475816..384ca50609 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -1,21 +1,18 @@ # dsh-scope -Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle. +Scoped Cordis registrations. `createScope(ctx, key)` returns a context whose registrations are visible only to the matching dispatch subject and are owned by one backing fiber. The agent loop creates one scope per live agent; lower-level packages depend only on the generic `ScopeKey` mechanism. ## Public API -- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`). -- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). -- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). -- `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. -- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). -- `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. -- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. -- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. +- `createScope(ctx, key): Scope` creates a tagged child context. Derived contexts inherit the tag; a nested scope replaces it. Primitive keys and creation during disposal throw. +- `Scope.ctx` is the registration context. +- `Scope.rawDispose` is the exact Cordis disposer used when nesting the scope in a composite effect. +- `Scope.dispose(): Promise` is the idempotent quiescence boundary for ordinary callers, including races started through `rawDispose`. +- `scopeOf(ctx)` returns the nearest key or `undefined` for global registration. +- `scopeTarget(base, key): Scoped` creates the event receiver that admits global listeners plus listeners for `key`. An undefined key admits only global listeners; Cordis `{ global: true }` remains an explicit bypass. +- `Scoped` brands scope-filtered event receivers at compile time. `isScopeCarrier()` and `carrierKeyOf()` support runtime invariants. +- `scopeHost(ctx, services)` provides a test/tooling host whose disposer awaits its fiber and all scopes it minted. -## Design contract +Visibility and cleanup come from the same registration context, so a contribution cannot be visible to one scope but owned by another. A scoped context retains the minting plugin's injected service view; mint it from a context whose capabilities are appropriate for holders. -Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md). - -Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach. +See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for rationale and lifecycle integration. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a7032b0b6..13ef552dd8 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -1,24 +1,7 @@ /** - * Scoped-context primitive: mint a Cordis context that TAGS everything - * registered through it with an opaque {@link ScopeKey}, and dispatch events so - * listeners registered through such a context fire only for their key's - * subject. Scope-aware registries (`ctx.tools`, `ctx.systemPrompt`) read the - * tag via {@link scopeOf} to file a registration in the right layer; the agent - * loop is the one scope MINTER today (one scope per live agent, key = the - * `Agent` object — see `Agent.ctx` in `@deepseek-ai/dsh-agent`), but the - * mechanism is key-agnostic by design so packages below the agent layer - * (`dsh-session`, `dsh-system-prompt`) can depend on it without a dependency - * cycle. - * - * Ownership and visibility derive from ONE fact — which context a registration - * went through: the scope's fiber owns the disposal (a `ctx.effect()`/ - * `ctx.on()`/registry call through the scoped context unwinds on - * {@link Scope.dispose}, because Cordis routes a service method's `this.ctx` - * to the ACCESSING context), and the tag decides who sees it. Splitting those - * two — an explicit `{ scope }` registration parameter — would let a caller - * express "visible to X, disposed with Y", which is almost always a bug; the - * scoped context makes it unrepresentable. - * + * Scoped-context primitive: mint a Cordis context that TAGS everything registered through it + * with an opaque {@link ScopeKey}, and dispatch events so listeners registered through such a + * context fire only for their key's subject. * @module @deepseek-ai/dsh-scope */ @@ -113,18 +96,6 @@ function scope(): void {} /** * Mint a registration scope for `key` under `ctx`. * - * Mounts a runtime fiber (`ctx.plugin`) and tags a child of its context with - * `key`. The fiber is usable synchronously — Cordis activates it on a - * microtask, but effect collection is uid-gated (not state-gated) and service - * resolution falls through the pending fiber to the MINTING plugin's - * dependency surface, so a caller may register through {@link Scope.ctx} the - * moment this returns. - * - * Service resolution through the scoped context flows through the minting - * plugin's dependency chain (the fiber walk), regardless of what the eventual - * holder's own fiber injected — handing out the scoped context hands out that - * capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's - * contract. * @param ctx - the context to mount the scope under; its fiber must be active * (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's * `inject` surface is what the scoped context resolves services against. @@ -172,38 +143,11 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { } /** - * Build the dispatch carrier for a scope-filtered event: `base` overlaid with - * a `Context.filter` that admits a listener iff - * - * - its registering context is UNTAGGED (a context-global listener — the - * compatibility default: plain plugin listeners see every subject), or - * - its tag IS `key` (a scoped listener seeing exactly its own subject), - * - * AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits - * it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a - * tool call with no calling agent or a bare (agent-less) session's events — - * admits only untagged listeners: a scoped listener never fires for someone - * else's (or nobody's) subject. Listeners registered `{ global: true }` - * bypass all filtering (Cordis semantics). - * - * Use it as the `thisArg` of the dispatch: - * `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The - * carrier is a TRANSPARENT proxy over `base`: reads delegate with `base` as - * the receiver and retrieved methods are bound to `base`, so a listener may - * call subject methods through its `this` (`this.send(…)` on a - * `Scoped`) even when the subject uses native `#private` fields — a - * bare proxy receiver would throw on those. Identity is still not - * transparent: `this !== subject` and method identity varies per read; the - * subject always travels in the event's arguments. The returned carrier is - * branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} / - * {@link carrierKeyOf}) so both the type system and the dev invariants can - * tell a carrier from a bare subject. - * @param base - the object the event is dispatched on behalf of (the owning - * service, or the subject agent itself); its own `Context.filter` is - * preserved and composed. - * @param key - the subject's scope key, or `undefined` for a subject-less - * dispatch. - * @returns the carrier to pass as the dispatch `thisArg`. + * Build an event receiver admitting global listeners plus listeners tagged with `key`. + * The proxy preserves `base` filtering and binds subject methods to `base`. + * @param base - dispatch subject whose filter is preserved. + * @param key - subject scope, or `undefined` for global-only delivery. + * @returns branded receiver for the dispatch `thisArg`. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] @@ -216,39 +160,20 @@ export function scopeTarget(base: T, key: ScopeKey | undefined [CordisContext.filter]: filter, [kCarrier]: { key }, } - // A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with - // the PROXY as receiver, so a getter on `base` runs with proxy `this` and a - // method call through the carrier gets a proxy receiver — either one throws - // on a native `#private` field of the subject (TypeError: private member - // not declared). Cordis hands the carrier to listeners as `this`, and the - // event declarations type it `Scoped` — so subject method calls - // through it are a SUPPORTED shape and must reach the real object: gets - // delegate with `base` as receiver, functions come back bound to `base`, - // and sets land on `base` directly. + // Bind through the real subject so native private fields remain accessible. return new Proxy(base, { get(target, prop) { - // Proxy get invariants pin what this trap may report for a - // non-configurable OWN property of the base: a non-writable data prop - // must be reported AS-IS (neither overlaid nor bound), a getterless - // accessor as undefined — checked FIRST so even an overlay key - // colliding with a frozen own prop of a (pathological) base yields the - // base's value instead of an engine TypeError. Such a base forgoes - // scope filtering; no production base freezes these keys. + // Non-configurable own properties must be reported unchanged. const own = Reflect.getOwnPropertyDescriptor(target, prop) const pinned = own !== undefined && own.configurable === false && own.get === undefined && own.writable !== true - // hasOwn, not `in`: the overlay literal inherits Object.prototype, so - // `in` would claim `toString`/`constructor` and shadow the subject's. + // `in` would let Object.prototype shadow subject properties. if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop] const value: unknown = Reflect.get(target, prop, target) if (typeof value !== 'function' || pinned) return value - // `constructor` is looked up, never invoked as a subject method — keep - // the real one (withProps special-cases it the same way), so - // `carrier.constructor` still identifies the subject's class. + // Preserve class identity. if (prop === 'constructor') return value - // `Function.prototype.bind` types as `any`; the value is structurally - // T[prop] and the trap's contract is untyped (`any`), so unknown is the - // honest safe return. + // `bind` is typed as `any`; keep the trap boundary `unknown`. return value.bind(target) as unknown }, set(target, prop, value) { @@ -312,14 +237,9 @@ export interface ScopeHost { } /** - * Mount a scope-minting host plugin that injects `services`, THE sanctioned - * way to mint scopes in tests (production scopes are minted by the agent - * loop). Exists because the naive spelling fails confusingly twice over: - * a plugin with no `inject` mints scopes whose service reads throw Cordis's - * cryptic `cannot get property … without inject`, and a plugin whose inject - * can never be satisfied RESOLVES its fiber await without ever running the - * callback — a silent no-op host. This helper fails LOUD instead: when the - * callback did not run, it names the absent services and disposes the host. + * Mount a scope-minting host plugin that injects `services`, THE sanctioned way to mint scopes + * in tests (production scopes are minted by the agent loop). + * * @param ctx - the context to mount the host under. * @param services - the service names scopes minted through this host reach * (the host plugin's `inject` list). @@ -349,9 +269,7 @@ export async function scopeHost(ctx: Context, services: string[]): Promise() let disposing: Promise | undefined const dispose = async (): Promise => { - // Start every boundary before awaiting any one of them. A child whose raw - // disposer already ran is still followed through Scope.dispose(); a child - // the host unload claims first is followed through the same fiber inertia. + // Start every boundary before awaiting any one of them. const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())] const results = await Promise.allSettled(tasks) scopes.clear() diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 44b9cf4442..84b8645069 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -209,12 +209,8 @@ describe('scopeTarget dispatch filtering', () => { }) it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => { - // The ds-review-bot regression: cordis hands the carrier to listeners as - // `this` (typed Scoped), so subject method calls through it are a - // supported shape. A proxy that delegates with the PROXY as receiver - // (cordis withProps) throws TypeError on any native #private the method - // or getter touches; the carrier must delegate with the BASE as receiver - // and bind retrieved methods to it. + // The ds-review-bot regression: cordis hands the carrier to listeners as `this` (typed + // Scoped), so subject method calls through it are a supported shape. class Subject { #count = 0 bump(): number { return ++this.#count } @@ -252,11 +248,9 @@ describe('scopeTarget dispatch filtering', () => { }) it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => { - // Pathological but engine-enforced: a base whose own [Context.filter] is - // a non-configurable, non-writable data prop pins what any proxy over it - // may report for that key. The carrier must yield the base's value (an - // overlay there would be a runtime TypeError from the engine, not a - // filtering choice). Such a base forgoes scope filtering by construction. + // Pathological but engine-enforced: a base whose own [Context.filter] is a + // non-configurable, non-writable data prop pins what any proxy over it may report for that + // key. const pinnedFilter = (): boolean => true const base = {} Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d3f4ca4890..be049ea9b2 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -3,6 +3,7 @@ * the derived LLM message history. Persistence is a plugin concern (subscribe * to `session/event`, drain on `session/flush`). * + * Scope-filtered dispatch: keyed to the session's captured owner. * @module @deepseek-ai/dsh-session */ @@ -35,44 +36,24 @@ declare module 'cordis' { interface Events { /** * 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. + * Dispatch uses the session's captured owner scope. * @param session - the session just entered and announced. * @mode emit */ 'session/created'(this: Scoped, session: Session): void /** - * 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. * @param session - the session whose log grew. * @param event - the appended event, exactly as recorded. * @mode emit */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** - * 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 {@link 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. * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ @@ -137,13 +118,7 @@ export class Session { constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) { if (seed) { - // Validate the seed to the SAME invariants `append` enforces, so a - // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a - // live log that no persistence backend could store: each event's `data` - // must be JSON-serializable, and `seq` must be contiguous from 0 (the - // `seq = log.length` contract the whole system relies on). Without this, - // a bad seed would surface only later as a backend rejection or a silent - // divergence between the live log and disk. + // Validate seed JSON and contiguous sequence numbers just as append would. seed.forEach((event, index) => { if (event.seq !== index) { throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`) @@ -151,25 +126,13 @@ export class Session { if (!isJsonValue(event.data)) { throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`) } - // Surface-eligible events MUST carry a surfaceOp marker — the surface is - // the sole source of derived history, so a marker-less message event - // would load fine yet vanish from deriveMessages(). `append` enforces - // this at compile time via its typed overload; a seed arrives as raw - // SessionEvent[] (replay/fork/load), bypassing that, so re-check at - // runtime here rather than silently resuming with empty history. + // Seed events bypass append's overloads, so enforce surface markers at runtime. if (isSurfaceEligibleType(event.type) && (event as SessionEvent).surfaceOp === undefined) { throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`) } }) - // Deep-clone each seed event, NOT just the array: the seed events and - // their `data` are still owned by the caller (or the source session of a - // fork), so keeping the references would let a post-create mutation of the - // original rewrite this session's durable log — or reintroduce a - // non-JSON-serializable value AFTER the validation above. Snapshotting at - // the boundary makes `session.events` independent and keeps it equal to - // what was validated. Serializability is guaranteed by the check above, so - // structuredClone can never hit a non-cloneable value here. + // Clone seed events so callers cannot mutate the durable log after validation. this.log = seed.map(event => structuredClone(event)) } this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } @@ -189,29 +152,14 @@ export class Session { } /** - * Append one typed event to the log and synchronously notify observers via - * `onAppend`. The hot path never blocks on I/O — persistence plugins buffer - * asynchronously. + * Append one typed event to the log and synchronously notify observers via `onAppend`. The + * hot path never blocks on I/O — persistence plugins buffer asynchronously. * * @param type - The event type (key of {@link SessionEventMap}). * @param data - The event payload; must be JSON-serializable. - * @param opts - Surface metadata: `surfaceOp` controls how the event enters - * the surface linked list; `sourceEventSeqs` records provenance (the seq - * numbers of events this one derives from). REQUIRED for - * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived history) and - * rejected by the compiler for non-surface types like `turn/start` or - * `assistant/chunk`. - * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of - * `data` that entered the log, so reading `event.data` back sees the logged - * value, never the caller's still-mutable input. - * @throws if `data` is not losslessly JSON-serializable (BigInt, function, - * symbol, undefined, non-finite number, circular ref, or an exotic object - * like Map/Set/Date). The event log is the durable source of truth, so this - * invariant is enforced at the source — a bad event never enters the log, - * keeping `session.events` always equal to what a backend can persist. The - * throw surfaces at the buggy caller's append site, not asynchronously in a - * backend flush. + * @param opts - required surface placement and optional provenance for message-producing events. + * @returns the event with assigned sequence, time, and snapshotted data. + * @throws if data is not losslessly JSON-serializable or surface placement is missing. */ append( type: T, @@ -222,36 +170,12 @@ export class Session { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } const surfaceOpts: SurfaceIntent | undefined = opts[0] - // Surface-eligible events MUST carry a surfaceOp marker — the surface is the - // sole source of derived history, so a marker-less message event would be - // logged yet vanish from deriveMessages(). The typed `opts` overload makes - // the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal; - // when `T` widens to the SessionEventType union (a caller iterating raw - // events: `for (const e of log) append(e.type, e.data)`), the conditional - // rest collapses to optional and the compiler stops enforcing it. Re-check - // at runtime so that loophole can't silently drop history. + // Recheck the conditional overload when `T` has widened to the full union. if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) { throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) } - // Snapshot `data` into the log, NOT the caller's reference: the validation - // above proves it is JSON-serializable AT THIS MOMENT, but the caller still - // owns the object and could mutate it afterwards (before a persistence - // flush, or permanently in the in-memory history) — making `session.events` - // diverge from the value that passed validation, or reintroducing a - // non-serializable value. Cloning here keeps the log equal to what was - // validated. structuredClone is safe because serializability was just - // checked. The returned event carries the SAME snapshot, so a caller reading - // back `event.data` sees the logged value, not its own mutable input. - // - // Surface metadata is snapshot separately: sourceEventSeqs (number[] — - // primitives, so array spread is a complete copy) and surfaceOp (a string - // primitive, or cloned if it's a replace object). - // Build the event shape with conditional surface fields via spreading. - // The result is cast through `unknown` because the conditional spreads - // produce an intersection type that the assignability checker can't - // narrow to a specific discriminated-union member when T is generic. - // This is a safe internal boundary: data was validated above, and - // surface metadata was snapshot from primitive/clone-safe values. + // Snapshot caller-owned data and metadata before they enter durable history. + // The generic conditional spreads require an internal union-boundary cast. const event = { type, seq: this.log.length, @@ -300,22 +224,9 @@ export class Session { private derivedGeneration = 0 /** - * Derive the LLM message history by walking the session surface — the linked - * list of message-producing events maintained by `surfaceOp` markers. The - * surface is the single source of derived history: every message-producing - * append records its `surfaceOp`, so a raw event with no marker (a chunk, a - * turn boundary) is correctly absent, and a compaction `replace` deletes the - * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, folded per node. + * Derive the LLM message history by walking the session surface — the linked list of + * message-producing events maintained by `surfaceOp` markers. * - * CACHED: each surface node is projected exactly once, when first seen — a - * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is - * a fresh snapshot per call (later appends never grow an array a caller - * already holds); the `Message` objects in it are SHARED and **deep-frozen** - * — cloned once off the log at projection time, so consumers can never - * mutate logged data, and mutation attempts throw instead of silently - * diverging replay from history. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[] { @@ -341,15 +252,10 @@ export class Session { } /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability RFC). The returned `content` is - * deep-cloned off the logged event: the log is append-only by contract, so - * no live reference to logged data leaves this boundary. + * Project a single event into the LLM message it derives to, or null when it produces none — + * a non-surface event (chunk, boundary, log-only record) or an empty-content + * assistant/message (which exists only to host usage). + * * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ @@ -441,31 +347,15 @@ export class SessionStore extends Service { } /** - * Create a session owned by the calling fiber: disposing that fiber stops - * event notification and removes the session from the store. `options.seed` - * populates the session with a copy of those events (replay/fork); - * `options.meta` attaches creation metadata (validated absolute `cwd`, - * `parentSession` lineage) as the immutable {@link SessionHeader} (the store - * fills `version`/`id`/`createdAt`). - * - * For an agent whose session must be torn down IN ORDER with its loop (so the - * loop's final flush is captured before `onAppend` detaches), do NOT use this - * — fold the session lifecycle into the agent's own effect via - * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s - * `startOwned`). - * + * Create, enter, and announce a session owned by the calling fiber. * @param id - the session id; omitted, the store mints `session-`. - * @param options - seed events and/or creation metadata for the header. + * @param options - optional seed and header metadata. * @returns the live session, already entered and announced. - * @throws if a session with `id` already exists, or if `meta.cwd` is a - * non-absolute path (storage backends key directories off it). + * @throws if the id exists or cwd is not absolute. */ create(id?: SessionId, options?: CreateSessionOptions): Session { const session = this.prepare(id, options) - // Single effect owned by the calling fiber. Yield the detach BEFORE - // announcing so a throwing `session/created` listener rolls the attach back - // (the generator effect disposes already-yielded disposers on a throw) - // instead of leaking the store entry + onAppend. + // Yield detach before announcement so listener failure rolls back entry. this.ctx.effect(function* (this: SessionStore) { yield this.enter(session) this.announce(session) @@ -474,13 +364,8 @@ export class SessionStore extends Service { } /** - * Build a session WITHOUT entering it into the store — validate the id/cwd and - * construct the {@link Session} (with its immutable {@link SessionHeader}). - * Pairs with {@link enter} + {@link announce}: a caller that owns a composite - * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE - * effect so a fiber unload tears the session + agent down as a single ORDERED - * chain rather than as racing sibling effects — which would detach `onAppend` - * before the loop's closing `session/flush`, dropping the closing events. + * Build a session WITHOUT entering it into the store — validate the id/cwd and construct the + * {@link Session} (with its immutable {@link SessionHeader}). * * @param id - the session id; omitted, the store mints `session-`. * @param options - seed events and/or creation metadata for the header. @@ -507,20 +392,8 @@ export class SessionStore extends Service { } /** - * Enter a {@link prepare}d session into the store: wire `onAppend` → - * `session/event` and add it to the store. Returns the DETACH disposer - * (`onAppend = undefined` + store removal). Does NOT emit `session/created` — - * the caller yields this disposer inside its effect and THEN calls - * {@link announce}, so a throwing `session/created` listener rolls the attach - * back instead of leaking it. - * - * Re-checks the id for a duplicate: `prepare` and `enter` are public - * cross-package primitives and a caller may interleave arbitrary work (or - * another create) between them, so a stale prepared session must NOT overwrite - * a live store entry of the same id — its detach disposer would later delete - * the REAL session. The {@link create} convenience and the agent factory call - * the two back-to-back so they never trip this, but the public seam cannot - * assume that. + * Enter a {@link prepare}d session into the store: wire `onAppend` → `session/event` and + * add it to the store. * * @param session - a {@link prepare}d session not yet in the store. * @returns the detach disposer (`onAppend = undefined` + store removal). @@ -528,11 +401,10 @@ export class SessionStore extends Service { */ enter(session: Session): () => void { if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`) - // The carrier is decided HERE, once, from the ENTERING context's scope tag - // (`this.ctx` is the caller's context — the tracker mechanism): every - // session/created|event|flush dispatch for this session uses it, so the - // session's whole event feed is scope-filtered consistently. The base is - // the session itself (scoped listeners' `this` is the session). + // The carrier is decided HERE, once, from the ENTERING context's scope tag (`this.ctx` is + // the caller's context — the tracker mechanism): every session/created|event|flush dispatch + // for this session uses it, so the session's whole event feed is scope-filtered + // consistently. const carrier = scopeTarget(session, scopeOf(this.ctx)) this.carriers.set(session, carrier) const emitCtx = this.ctx @@ -604,14 +476,12 @@ export class SessionStore extends Service { } /** - * Create a live child session from a turn-enclosed prefix of a live source. - * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must end at `turn/end`. + * Create a live child session from a turn-enclosed prefix of a live source. `boundary` is + * an inclusive source event seq; omitted means the source's current last event. * * @param source - Live source session object or id. - * @param boundary - Inclusive source event seq to fork through; omitted means - * the source's current last event, and omitted on an empty source forks an - * empty child. + * @param boundary - Inclusive source event seq to fork through; omitted means the + * source's current last event, and omitted on an empty source forks an empty child. * @param childSessionId - Optional child session id; omitted delegates to * `SessionStore`'s id policy. * @returns The created live child session. diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 22303c6c61..99ddc0b256 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -1,15 +1,5 @@ /** * JSON-serializability validation for session event data. - * - * The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every - * `event.data` must round-trip losslessly through JSON so any persistence - * backend can store and reload it byte-identically. This invariant belongs to - * the log itself — `Session.append` enforces it at the source, so a - * non-serializable event never enters `session.events` and the live log can - * never diverge from what a backend can persist. Backends re-use the same - * predicate to validate their own `append(events)` entry point (replay/fork - * paths that do not go through a live `Session`). - * * @module @deepseek-ai/dsh-session/json */ @@ -24,22 +14,9 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } /** - * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, - * booleans, strings, plain arrays, and plain objects of such values. Rejects - * `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`, - * which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/ - * `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or - * convert lossily. Sparse arrays are rejected too: a hole serializes to `null`, - * so `[1, , 3]` would not round-trip. Detects circular references (which would - * throw) and reports them as non-serializable rather than propagating the throw. + * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, booleans, + * strings, plain arrays, and plain objects of such values. * - * Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE - * STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and - * non-enumerable properties are NOT examined, because `JSON.stringify` likewise - * drops them — they never reach the durable form, so a non-serializable value - * hiding under a symbol/non-enumerable key cannot make the round-trip lossy. - * Getters are invoked during the check (again as `JSON.stringify` would), so the - * contract is for plain data records, not objects with side-effecting accessors. * @param value - the candidate event data to test. * @param seen - objects on the current descent path, for circular-reference * detection; the recursion threads it — callers omit it. diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index cb780da013..c320ccaa6a 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -1,37 +1,5 @@ /** * Crash-recovery repair for an interrupted session log. - * - * A persistence backend flushes only at `turn/end`, so a crash can leave a - * durable log whose final turn never closed: real, fully-written events sit - * after the last `turn/end` with no closing boundary. A single turn can be huge - * in a long-horizon task (many steps, large tool output), so those events MUST - * be preserved — truncating the turn would silently destroy real work. Instead, - * on reload the backend CLOSES the orphaned turn by appending the minimal - * synthetic boundary events: - * - * 1. an error `tool/result` for every `tool-call` in the interrupted turn that - * never got its matching `tool/result` (so the rehydrated history is a - * VALID provider transcript — see below), - * 2. a `step/end` if a step was still open, then - * 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason. - * - * The marker records that the turn was cut short by a crash, not completed by - * the model. See the session-persistence RFC. - * - * Why the synthetic tool results matter: `deriveMessages()` renders the - * `tool-call` blocks inside a durable `assistant/message` but only emits a - * matching tool-result when a `tool/result` EVENT exists. A crash between the - * assistant message and its tool results (the loop runs the tools AFTER logging - * the assistant message, so a process killed mid-tool leaves the calls without - * results) would otherwise reload a history with a dangling assistant tool-call - * — which every provider rejects as an invalid transcript on the next request. - * Synthesizing an error result per orphaned call keeps resume safe. - * - * This module computes those synthetic closers from an event list; backends - * return them inline from `load` (so the reconstructed session is balanced and - * immediately usable) and persist them during that mutating load before any - * later append continues the log. - * * @module @deepseek-ai/dsh-session/repair */ @@ -39,36 +7,19 @@ import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** - * Scan `events` for an open turn/step at the tail and return the synthetic - * boundary events that close them, with `seq` continuing the log and `time` - * copied from the last real event (the closers stand in for the crash moment; - * reusing the last timestamp keeps them deterministic and never invents a - * "future" time). Returns an empty array when the log is already balanced - * (ends on a `turn/end`, or is empty) — the common, non-crash case. + * Scan `events` for an open turn/step at the tail and return the synthetic boundary events + * that close them, with `seq` continuing the log and `time` copied from the last real event + * (the closers stand in for the crash moment; reusing the last timestamp keeps them + * deterministic and never invents a "future" time). * - * The closers, in order: an error `tool/result` for each unmatched `tool-call` - * in the interrupted turn, then a `step/end` if a step is open, then the - * `turn/end {interrupted}`. The tool-results come first so a step that issued - * tool calls is balanced (every call has a result) before its `step/end`. - * - * Only the LAST turn can be open: the invariants plugin guarantees a `turn/end` - * before any later `turn/start`, so an interior open turn is impossible in a - * valid committed log. Likewise at most one step is open within that turn. * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail). * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced. */ export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] { let openTurn: number | null = null let openStep: number | null = null - // Track tool calls vs. their results WITHIN the currently-open turn only: a - // call is "pending" until its matching tool/result arrives. Reset at every - // turn boundary so a committed earlier turn (already balanced) never leaks a - // phantom pending call into the interrupted-turn repair. - // Track pending tool calls with their callSeq (the seq of the `tool/call` - // event, captured for surface sourceEventSeqs provenance on the synthetic - // result). CallSeq is set from `tool/call` events; the assistant/message - // block scan may register a call first (it appears earlier in the log), and - // the later `tool/call` event fills in the seq. + // Track tool calls vs. their results WITHIN the currently-open turn only: a call is "pending" + // until its matching tool/result arrives. const pendingCalls = new Map() for (const event of events) { switch (event.type) { @@ -97,10 +48,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session } break case 'tool/call': - // Capture the tool/call event seq for surface provenance on the - // synthesized tool/result. The entry may already exist (registered by - // the assistant/message above) or may be new (if the assistant/message - // came from a prior step that was already closed). + // Capture the tool/call event seq for surface provenance on the synthesized + // tool/result. { const entry = pendingCalls.get(event.data.callId) if (entry) { @@ -129,10 +78,9 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session const time = last.time const closers: SessionEvent[] = [] - // Synthesize an error tool/result for each tool-call left unanswered by the - // crash, so deriveMessages() yields a valid provider transcript on resume (a - // dangling assistant tool-call is rejected by every provider). Insertion - // order follows the Map (insertion = log order of the assistant messages). + // Synthesize an error tool/result for each tool-call left unanswered by the crash, so + // deriveMessages() yields a valid provider transcript on resume (a dangling assistant + // tool-call is rejected by every provider). for (const [callId, { step, callSeq }] of pendingCalls) { closers.push({ type: 'tool/result', diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index eeb2fe40ed..cab0a2b73b 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -1,14 +1,6 @@ /** - * Request-header reconstruction utilities: the pure fold/diff/apply trio over - * the `request/header` / `request/header-delta` session events. Anyone - * holding a session log reconstructs the {@link EpochHeader} any request was - * built under by folding these events in log order; the loop uses the same - * functions to decide whether a step's header changed and to encode the - * change. Deltas are an encoding optimization with a safety valve — the - * writer round-trip-verifies every delta before appending and falls back to - * a full snapshot when the encoding cannot express the change — so folding - * never needs error recovery on a well-formed log. - * + * Request-header reconstruction utilities: the pure fold/diff/apply trio over the + * `request/header` / `request/header-delta` session events. * @module dsh-session/request-header */ @@ -114,13 +106,10 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[ } /** - * Field-wise equality over canonical headers — the cheap comparison the - * writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal - * the intended header) and the loop runs to skip logging an unchanged header. - * Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is - * correctly unequal; the session prefix compares as canonical JSON (both - * sides come from the same build path, so key order matches when the values - * do). + * Field-wise equality over canonical headers — the cheap comparison the writer's round-trip + * guard runs (`applyHeaderDelta(prev, delta)` must equal the intended header) and the loop + * runs to skip logging an unchanged header. + * * @param a - one canonical header. * @param b - the other. * @returns whether config, system, tools (in order), and the session prefix all match. @@ -139,13 +128,9 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | } /** - * Compute the `request/header-delta` payload between two canonical headers, - * or undefined when they are equal. The caller MUST round-trip the result - * ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it — - * the encoding cannot express every change (a pure tool reordering) — and - * fall back to a full `request/header` snapshot when the check fails. - * The session prefix is replaced whole (small advisory content, not worth - * diffing); an empty replacement array encodes the transition to "none". + * Compute the `request/header-delta` payload between two canonical headers, or undefined when + * they are equal. + * * @param prev - the folded header the log currently implies. * @param next - the header the next request will actually use. * @returns the delta payload, or undefined when nothing changed. @@ -182,15 +167,13 @@ export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHe } /** - * Fold the header events of a log (or any prefix of one) into the - * {@link EpochHeader} in force after the last of them: each - * `request/header` snapshot replaces the state, each `request/header-delta` - * amends it. The pure, offline form of reconstruction — external tooling and - * the dev invariant both use it; the live session tracks the same fold - * incrementally. + * Fold the header events of a log (or any prefix of one) into the {@link EpochHeader} in + * force after the last of them: each `request/header` snapshot replaces the state, each + * `request/header-delta` amends it. + * * @param events - session events in log order (non-header events are skipped). - * @param from - a previously folded state to continue from (the live session's - * incremental cursor); omit to fold from nothing. + * @param from - a previously folded state to continue from (the live session's incremental + * cursor); omit to fold from nothing. * @returns the folded header, or undefined when no header event exists yet. */ export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined { diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 6ea3042bd7..1333f08917 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -1,36 +1,6 @@ /** - * Tool-pairing balance over a session's SURFACE: is a given cut point in the - * surface a safe edge for a collapsed region (e.g. compaction)? - * - * The invariant a consumer needs: a collapsed region must never separate an - * `assistant/message`'s `tool-call` blocks from their answering `tool/result`s - * — that would leave the rehydrated transcript with a dangling tool-call or an - * orphaned tool-result, which every provider rejects. (This is the - * compaction-time mirror of the crash-recovery imbalance that - * {@link interruptedTurnClosers} repairs on load.) Steps were once used as a - * proxy for this bracketing, but a compaction REWRITES the surface — it lands a - * replacement node at a high log seq whose SURFACE position is the head — so a - * scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The - * pairing the invariant actually protects lives in the surface nodes' own - * content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels - * with the node through any reshaping, so alignment is decided over the surface - * directly. - * - * A **cut** is a gap between two adjacent surface nodes (named by the node it - * sits immediately before), or the after-tail gap (`null`). Walking the surface - * head→tail and assigning each node a delta — `+1` per `tool-call` block on an - * `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a - * cut is the number of still-unanswered tool calls before it. A cut is - * **balanced** when that depth is `0`. A region `[start..end]` is safe to - * collapse iff BOTH its edges are balanced cuts: the cut before `start` and the - * cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an - * inter-step `steering/message`, an injection `context/message`) carry no - * pairing, contribute `0`, and so are free boundaries — exactly as before, but - * now as a consequence of the balance rather than a special case. An open - * trailing step (an assistant whose `tool/result`s have not landed yet) keeps - * the depth positive through the tail, so no cut inside it is balanced — the - * old explicit open-step check falls out of the same counter. - * + * Tool-pairing balance over a session's surface: is a given cut point in the surface a safe + * edge for a collapsed region (e.g. compaction)? * @module @deepseek-ai/dsh-session/tool-pairing */ @@ -57,33 +27,12 @@ function nodeDelta(event: SessionEvent): number { } /** - * Whether the surface prefix ending at the given cut has BALANCED tool-call / - * tool-result brackets — i.e. every `tool-call` block on the surface before the - * cut has its answering `tool/result` before the cut too, so the cut is a safe - * edge for a collapsed region (it cannot split an assistant↔result pair). - * - * `nodes` is the surface linked list in head→tail order (e.g. - * `session.surface.nodes`); `events` is the session log, used to look each - * node's event up by `seq`. `beforeSeq` names the cut by the surface node it - * sits immediately before; the after-tail cut (the whole surface) is `null`, - * as is any `beforeSeq` not present on the surface. - * - * A region `[start..end]` is collapsible iff both edges are balanced cuts: call - * `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and - * `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s - * surface successor (`SurfaceNode.next`), or `null` when `end` is the tail — - * for the cut after `end`. - * + * Check that a surface cut does not split a tool call from its result. * @param nodes - the surface linked list in head→tail order. * @param events - the session log each node's `seq` indexes into. - * @param beforeSeq - names the cut (the node it sits immediately before); - * `null` — or any seq not on the surface — means the after-tail cut. - * @returns true when every `tool-call` before the cut is answered before it - * (the unanswered-call depth at the cut is zero). - * @throws if the surface prefix drives the unanswered-call depth negative — a - * `tool/result` with no preceding open `tool-call` on the surface. That is a - * corrupt surface (a structural invariant violation), surfaced loudly here - * rather than silently mis-classifying a boundary. + * @param beforeSeq - node immediately after the cut; absent from the surface means after-tail. + * @returns whether every call before the cut has its result before the cut. + * @throws if a result appears without a preceding open call. */ export function isToolPairingBalanced( nodes: readonly SurfaceNode[], @@ -93,14 +42,12 @@ export function isToolPairingBalanced( let depth = 0 for (const node of nodes) { if (node.seq === beforeSeq) return depth === 0 - // node.seq is a surface-node seq, always a valid log index by construction. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion depth += nodeDelta(events[node.seq]!) if (depth < 0) { throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) } } - // Reached the after-tail cut (beforeSeq === null, or a seq not on the - // surface): the whole-surface prefix is balanced iff depth returned to 0. + // A missing cut node means the after-tail boundary. return depth === 0 } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ca6779e1dc..d7fa142ec7 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -14,19 +14,9 @@ export function SessionId(id: string): SessionId { } /** - * The on-disk session format version, stamped into every newly-written - * {@link SessionHeader} and enforced by every persistence backend on load. The - * single source of truth for the version — write sites and the load-time check - * all read it. - * - * It is **`0`** deliberately: while the harness is unreleased the on-disk format - * is **unstable / pre-release, with no compatibility implied**. Breaking changes - * to the persisted {@link SessionEventMap} shape (folding fields onto an event, - * removing a variant, …) happen freely and do NOT bump this — v0 absorbs all - * pre-release churn, and a backend simply REJECTS any log not at v0 (there is no - * migration; no persisted user data exists to preserve). A real, monotonically - * bumped version policy begins at the first tagged release, when a specific - * format boundary becomes worth distinguishing. + * The on-disk session format version, stamped into every newly-written {@link SessionHeader} + * and enforced by every persistence backend on load. The single source of truth for the + * version — write sites and the load-time check all read it. */ export const SESSION_FORMAT_VERSION = 0 @@ -55,13 +45,8 @@ export interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by - * this session — the seed boundary. Set when a fork seeds a child with a - * prefix of the parent's log (= the seeded prefix length); absent/0 means the - * session produced all its own events. Persisted so a reload reconstructs the - * boundary instead of re-deriving it from the full stored log, and so a replay - * harness can skip the inherited prefix when deriving the child's OWN script - * (the seeded events are the parent's, not this child's model calls). + * How many leading events were INHERITED via a seed rather than produced by this session — + * the seed boundary. */ seedLength?: number } @@ -110,21 +95,7 @@ export interface TurnTriggerMap { export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] /** - * Why a turn ended. - * Merge-extensible sum type. - * - * `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's - * `length`): the turn ended because a step hit the output-token ceiling, not - * because the model chose to stop. The agent-loop surfaces it via the rule - * "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a - * continuation plugin can run further steps after one, but the cut-short fact - * still wins). It is distinct from `completed` so a consumer (e.g. the ACP - * bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a - * truncated one. The next variants to add — when an adapter/loop first emits - * them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP - * stop reasons); no current adapter produces a `refusal` finish (unknown - * DeepSeek finish reasons collapse to `error`), so it is deliberately omitted - * until one does. + * Why a turn ended. Merge-extensible sum type. */ export interface TurnEndReasonMap { completed: { kind: 'completed' } @@ -149,14 +120,8 @@ export interface TurnEndReasonMap { */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a - * persistence backend later closed the orphaned (open) turn on reload so the - * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no - * loop ever emits this. Its events are real (they were durably appended before - * the crash) and are PRESERVED, not discarded: a single turn can be huge in a - * long-horizon task (many steps, large tool output), so truncating it would - * lose real work. The marker records that the turn was cut short, not that the - * model completed it. See the session-persistence RFC. + * The turn never ended on its own: the process crashed mid-turn and a persistence backend + * later closed the orphaned (open) turn on reload so the log stays balanced. */ interrupted: { kind: 'interrupted' } } @@ -253,24 +218,10 @@ export interface ToolsDelta { } /** - * The session event vocabulary — the append-only source of truth for an - * agent's whole interaction history. The LLM message history is *derived* - * from this log; nothing else is authoritative. Replay = re-derive from the - * same events; trace/telemetry = subscribe to the log. - * - * Merge-extensible: plugins declare extra event types via declaration merging - * (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`, - * `'compact/end'`). - * - * Durability contract (what a persistence backend relies on): the durable log - * persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay - * contiguous (`seq = log.length`), so chunks cannot be filtered out of the - * canonical log. All `event.data` must be JSON-serializable — `Session.append` - * (and the seed path in the constructor) enforces this at the source (throwing - * on non-serializable data), so a bad event never enters the log and - * `session.events` always equals what a backend can persist. Adding a new event - * type that carries non-serializable data, or that breaks the turn/step nesting - * the invariants plugin checks, is a breaking change to the on-disk format. + * The session event vocabulary — the append-only source of truth for an agent's whole + * interaction history. The LLM message history is *derived* from this log; nothing else is + * authoritative. Replay = re-derive from the same events; trace/telemetry = subscribe to the + * log. */ export interface SessionEventMap { /** @@ -293,14 +244,8 @@ export interface SessionEventMap { /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * 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 - * ({@link PromptDecision} `block.reason`). NOT a {@link 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. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** @@ -337,47 +282,24 @@ export interface SessionEventMap { /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** - * 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 {@link 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. + * 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 })`. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, - * with the {@link 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 - * {@link 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 {@link EpochHeader} the NEXT request is built under, with the {@link + * RequestHeaderReason} it was recorded whole. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Amendment to the folded {@link EpochHeader}: at least one of a - * {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement - * {@link 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 {@link SurfaceEventType}. + * Amendment to the folded {@link EpochHeader}: at least one of a {@link SystemDelta}, a + * {@link ToolsDelta}, a whole replacement {@link 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). */ 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index b416d15639..a2a550bfcd 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -1,11 +1,4 @@ -/** - * Derived-message cache tests: the session projects each surface node exactly - * once (O(new nodes) per call), rebuilds on a surface rewrite (replace / - * invalidate — the replaceGeneration signal), returns a fresh array snapshot - * per call over shared frozen messages, and stays deep-equal to a from-scratch - * replay derivation at every step — the incremental==scratch property the - * reconstructability RFC's invariant enforces in dev at request time. - */ +/** Derived-message cache behavior against a from-scratch replay oracle. */ import { describe, expect, it } from 'vitest' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -28,7 +21,6 @@ describe('derived-message cache', () => { userText(session, 'two') session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual(scratch(session)) - // An empty-content assistant/message (usage host) projects to nothing. session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual(scratch(session)) }) @@ -48,7 +40,6 @@ describe('derived-message cache', () => { expect(session.deriveMessages()).toHaveLength(1) expect(session.deriveMessages()).toEqual(scratch(session)) - // The array a caller took before the replace is untouched. expect(beforeReplace).toHaveLength(2) }) @@ -61,7 +52,7 @@ describe('derived-message cache', () => { const second = session.deriveMessages() expect(first).toHaveLength(1) expect(second).toHaveLength(2) - // Shared projection objects: the same frozen message instance, once ever. + // Array snapshots share their frozen message projections. expect(second[0]).toBe(first[0]) expect(Object.isFrozen(first[0])).toBe(true) }) @@ -74,7 +65,6 @@ describe('derived-message cache', () => { session.surface.invalidate() const after = session.deriveMessages() expect(after).toEqual(before) - // A rebuild re-projects: fresh objects, same values. expect(after[0]).not.toBe(before[0]) }) }) @@ -84,8 +74,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => { const session = new Session(SessionId('per-event')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // The fold path (deriveMessages) and the per-event path share the - // projection, so an external reconstructor cannot disagree with the cache. + // Full and per-event derivation share one projection. expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1)) }) diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index bc4ab4fbb1..cca9cbfa61 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -1,16 +1,6 @@ /** * Negative-path tests for the persistence log catalog generator * (`scripts/gen-persistence-catalog.ts`). - * - * The generated catalog is frozen by a regenerate-and-diff freshness gate, so - * the freshness half is exercised by `pnpm run verify-persistence-catalog` in - * CI. What a freshness diff CANNOT prove is that the generator REJECTS - * malformed source the way it promises to — a member without description - * prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event - * declaration, a missing or ambiguous `SurfaceEventType` union, a stale union - * member. These tests drive the exported collectors against synthetic fixture - * packages to prove each guard fires (and that well-formed declarations pass), - * mirroring the gen-cordis-catalog negative tests. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index c3a887e14f..89973c4d7a 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -13,10 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' -// An appendable event: its type/data plus, for surface-eligible types, the -// explicit surface intent the generator declares (mirroring how a real caller -// passes it). The intent is part of the generated fixture, NOT synthesized by -// `build`, so each arbitrary states the marker it produces. +// An appendable event: its type/data plus, for surface-eligible types, the explicit surface +// intent the generator declares (mirroring how a real caller passes it). type Appendable = { [T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent } }[SessionEventType] diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index e94a9b437f..2803b7ab3f 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -155,11 +155,9 @@ describe('interruptedTurnClosers', () => { }) it('handles tool/call without a matching assistant/message entry gracefully', () => { - // A tool/call event exists in the log but no assistant/message registered - // the callId in pendingCalls (e.g., a plugin appended it directly, or the - // assistant/message from a prior step didn't have this call). The repair - // should still close the turn — it just won't synthesize a result for this - // call (there's nothing to answer). + // A tool/call event exists in the log but no assistant/message registered the callId in + // pendingCalls (e.g., a plugin appended it directly, or the assistant/message from a prior + // step didn't have this call). const events: SessionEvent[] = [ userTurnStart(1, 0), { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f63353af9b..e82486b1f4 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -81,9 +81,6 @@ describe('Session', () => { const before = structuredClone(session.events) // A misbehaving consumer tries to mutate the messages it was handed. - // Derived messages are frozen shared projections (cloned once off the - // log, then deep-frozen): every mutation attempt THROWS in strict mode — - // isolation by unrepresentability, not by per-call cloning. const messages = session.deriveMessages() const userBlock = messages[0]!.content[0]! expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError) @@ -132,11 +129,8 @@ describe('Session', () => { it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { const session = new Session(SessionId('s5b')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // The typed overload makes surfaceOp mandatory only when the type argument is - // a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it - // to the SessionEventType union, where the conditional rest collapses to - // optional — the exact shape `for (const e of log) append(e.type, e.data)` - // produces. Reproduce that here and assert the runtime guard rejects it. + // The typed overload makes surfaceOp mandatory only when the type argument is a SPECIFIC + // SurfaceEventType literal. const widenedType = 'user/message' as SessionEventType expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) .toThrow(/surface-eligible and requires a surfaceOp marker/) @@ -259,10 +253,8 @@ describe('SessionStore', () => { }) it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => { - // prepare()/enter() are public cross-package primitives that a caller may - // separate with arbitrary work. A stale prepared session must NOT overwrite - // a live store entry of the same id — its detach disposer would later delete - // the REAL session, breaking the store-uniqueness invariant. + // prepare()/enter() are public cross-package primitives that a caller may separate with + // arbitrary work. const ctx = new Context() await ctx.plugin(SessionStore) const stale = ctx.sessions.prepare(SessionId('racy')) diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 307b0d8658..7dfecf23ba 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -4,24 +4,7 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' import type { SessionEvent, SurfaceNode } from '../src/index.ts' /** - * Unit coverage for the tool-pairing balance check. It decides whether a CUT in - * the surface (a gap before a given surface node, or the after-tail gap) is a - * safe edge for a collapsed region (compaction): a region must never split an - * `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced - * when no unanswered tool-call sits before it on the surface. Nodes belonging to - * no step (pre-step user message, inter-step steering, injection context) are - * pairing-neutral, so their cuts are free boundaries. - * - * The fixtures are built through a real {@link Session} so the surface linked - * list is derived exactly as production does — including the non-monotonic - * surface a `replace` op leaves (a compaction checkpoint at a high log seq - * sitting at the surface head), which is the case the abandoned log-position - * scan mis-classified. - * - * Builders mirror the agent loop's real append order: queued user messages land - * BEFORE `step/start`; within a step the order is `assistant/message` then - * `tool/result`(s); injection turns are a bare `turn/start → context/message → - * turn/end` with no step. + * Unit coverage for the tool-pairing balance check. */ const SURFACE = { surfaceOp: 'append' as const } @@ -182,10 +165,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message }) describe('isToolPairingBalanced — a mid-step injection context/message', () => { - // A background task-done inject() lands a context/message INSIDE an open step, - // between the assistant (with a tool-call) and its tool/result. It is - // pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is - // still open across it) — it is NOT a free boundary in this position. + // A background task-done inject() lands a context/message inside an open step, between the + // assistant (with a tool-call) and its tool/result. function midStepInjection(): Session { const s = new Session(SessionId('mid-inject')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -236,11 +217,7 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => { }) describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { - // The case the log-position scan got wrong. After a compaction, a replacement - // user/message lands at a HIGH log seq but sits at the SURFACE head, beside - // the still-open step whose events follow it in the log. It carries no - // tool-call/result pair (just summarized prose), so it must be a balanced cut - // on BOTH sides regardless of its log neighbours. + // The case the log-position scan got wrong. function checkpointHeadedSession(): Session { const s = new Session(SessionId('checkpoint')) // A closed turn with a tool step → surface [u1, asst(call), result]. @@ -291,10 +268,8 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace }) it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { - // This is the exact assertion the log-position scan failed: the forward log - // scan from the checkpoint reached the open step's assistant/message and - // wrongly reported mid-step. The surface balance sees a neutral node whose - // following cut closes no open call. + // This is the exact assertion the log-position scan failed: the forward log scan from the + // checkpoint reached the open step's assistant/message and wrongly reported mid-step. const s = checkpointHeadedSession() expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) }) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6d3ed3af58..cd9f386d9a 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,15 +1,6 @@ /** - * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, named prompt variables, and authoritative named - * protections; `assemble(context)` collates them through a waterfall that - * runs once per step, restores protected contributions, and `renderPrompt` - * interpolates `{{variable}}` references into the final text. - * - * The harness-owned prompt openers live here too: this plugin registers the - * static `harness:identity` section (order −100) and the deployment's - * `deployment:persona` section (order 0, from its `persona` config), so they - * exist for every agent regardless of which loop plugin drives it. - * + * System prompt assembly registry. + * Scope-filtered dispatch: keyed to `context.scope`. * @module @deepseek-ai/dsh-system-prompt */ @@ -26,20 +17,14 @@ declare module 'cordis' { interface Events { /** - * Waterfall around prompt assembly — mutate or extend the - * {@link PromptAssembly} (sections + tools + variables) before it is - * rendered. Bound to the {@link SystemPrompt} service; call `next()` to - * delegate. - * @param assembly - the assembly built from the registered sections, tool - * providers, and variable providers; listeners may mutate it or return a - * replacement. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed - * by `context.scope` — a listener registered through `agent.ctx` fires only - * for that agent's assemblies; a plain plugin listener fires for every - * assembly (scope-less ones included, dispatched subject-less). - * @param context - the per-assembly {@link AssembleContext} the caller - * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt - * is for), so a listener can filter or extend per agent. + * Waterfall around prompt assembly — mutate or extend the {@link PromptAssembly} + * (sections + tools + variables) before it is rendered. + * + * @param assembly - the assembly built from the registered sections, tool providers, + * and variable providers; listeners may mutate it or return a replacement. + * @param context - the per-assembly {@link AssembleContext} the caller passed to {@link + * SystemPrompt.assemble} (e.g. which agent the prompt is for), so a listener can + * filter or extend per agent. * @mode waterfall */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise @@ -124,17 +109,6 @@ export interface ToolProviderResult { /** * Canonical prompt contributions that survive the assembly waterfall. - * - * Protection is declarative by contribution name rather than an ordered - * callback: after every `system-prompt/assemble` listener has finished, the - * service restores each protected name to the exact presence and definition - * produced by its registries before the waterfall. Restored entries keep - * canonical order with one another and anchor before their first surviving - * later unprotected canonical neighbor (or at the end); the service does not - * undo a listener's reordering of unprotected entries. A name absent from that - * canonical assembly is removed from the result. This makes mode-dependent - * absence protectable too (for example, a native tool that intentionally stays - * off the wire in Code Mode). */ export interface PromptProtection { /** Section names whose canonical registry output is authoritative. */ @@ -145,19 +119,6 @@ export interface PromptProtection { /** * The assembled prompt. - * - * Tool schemas are part of the assembly by design: "what the model is told it - * can do" is one coherent thing managed here, even though adapters transmit - * `tools` as a separate wire field rather than prompt text. They arrive in - * the canonical model-facing order (see {@link Config.toolOrder}). - * - * `variables` carries every registered prompt variable resolved against this - * assembly's context — key present means registered, `undefined` value means - * "no value for this assembly" (referencing it renders an error). Section - * texts are resolved but NOT yet interpolated; {@link renderPrompt} applies - * the variables, so waterfall listeners can still add sections or variables. - * - * Merge-extensible: plugins can declare extra fields on this interface. */ export interface PromptAssembly { sections: AssembledSection[] @@ -202,20 +163,9 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine } /** - * Order collected tool schemas by the validated policy: with no configured - * list, plain lexicographic name order; with one, listed names take their - * listed position and every unlisted tool lands at the - * {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed - * name outside `knownNames` — the providers' PRE-restriction name universe — - * throws: misconfiguration fails loud, and each assembly is the earliest - * moment the registered tool set exists to check against (tool plugins - * register after the service constructs, so load time is too early); the - * assembly rejects, failing the caller's turn before any model request. A - * listed name that is KNOWN but not collected (a tool restricted away for - * this assembly's scope) is a normal absence: its position simply - * contributes nothing — `toolOrder` stays compatible with per-agent - * `restrict()` masks. Never drops a collected tool, and both sorts are - * stable, so tools sharing a name keep their collection order. + * Order collected tool schemas by the validated policy: with no configured list, plain + * lexicographic name order; with one, listed names take their listed position and every + * unlisted tool lands at the {@link TOOL_ORDER_REST} rest entry in lexicographic name order. */ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet): ToolSchema[] { const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST) @@ -240,10 +190,7 @@ function restoreProtected( const restored = result.filter(entry => !protectedNames.has(entry.name)) for (const [index, entry] of canonical.entries()) { if (!protectedNames.has(entry.name)) continue - // Protected entries are inserted in canonical order. Anchor each one - // before the first later UNPROTECTED canonical neighbor that survived the - // waterfall; if none survived, it belongs at the end. Looking only at - // unprotected neighbors avoids reversing adjacent protected entries. + // Protected entries are inserted in canonical order. const following = new Set( canonical.slice(index + 1) .filter(candidate => !protectedNames.has(candidate.name)) @@ -263,58 +210,23 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number { /** 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} (`''`) 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} (`''`) entry in lexicographic name order. */ toolOrder?: string[] } /** - * Renders the text part of an assembly: interpolates `{{variable}}` - * references in each section from `assembly.variables`, drops empty sections, - * and joins the rest with blank lines. + * Renders the text part of an assembly: interpolates `{{variable}}` references in each section + * from `assembly.variables`, drops empty sections, and joins the rest with blank lines. * - * Strict by design (fail loud beats shipping a malformed prompt): a reference - * to an unregistered variable, to a registered variable with no value for - * this assembly, a complete `{{…}}` group that is not a well-formed variable - * name (e.g. `{{ model }}`), or a `{{` that does not open a complete group - * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A - * lone `{{` with no `}}` anywhere after it is ordinary prose and passes - * through verbatim. Substituted values are never re-scanned. * @param assembly - the assembly to render (typically the awaited result of * {@link SystemPrompt.assemble}); only `sections` and `variables` are read. * @returns the full system prompt text; `''` when every section renders empty @@ -379,12 +291,9 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ persona: z.string().default(''), - // A schemastery array defaults to [] when omitted, but an omitted - // toolOrder must stay absent ("lexicographic order"), not become an - // explicitly-configured empty list (which is invalid — it lacks the - // rest entry). Forcing the default to undefined keeps the key out of the - // validated config; the cast is needed because .default() expects the - // array type. + // A schemastery array defaults to [] when omitted, but an omitted toolOrder must stay + // absent ("lexicographic order"), not become an explicitly-configured empty list (which is + // invalid — it lacks the rest entry). toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -402,12 +311,7 @@ export class SystemPrompt extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) - // The harness-owned openers. They live HERE (not on the loop plugin) so a - // deployment that swaps in a different loop keeps them: the identity is a - // harness fact stated ahead of everything, and the persona is the - // deployment's config, one section of the full prompt, never the whole. - // An empty persona still RESERVES the section name (one owner — a plugin - // re-registering it throws); renderPrompt drops the empty text. + // The harness-owned openers. this.section({ name: 'harness:identity', order: -100, @@ -423,22 +327,8 @@ export class SystemPrompt extends Service { } /** - * Contribute a text section to the system prompt. Order is determined by - * `section.order` (ascending). The layer is decided by the CALLING context - * (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a - * scoped context (`agent.ctx`) contributes to that scope alone — and a - * scoped section SHADOWS a same-named global section for that scope's - * assemblies (most-specific-wins; this is how a per-agent persona overrides - * `deployment:persona`) unless that global name is protected: global - * protection reserves its section name against scoped shadows so the - * registration owner—not a later scope—defines the canonical value. The - * registry snapshots `name`, `order`, and `text` before checking/storing, so - * later caller-object mutation cannot rename a contribution. Throws - * if the SAME layer already has the name (a - * duplicate would silently double prompt text — e.g. a double-loaded tool - * plugin; the global-duplicate message names `agent.ctx` as the per-agent - * alternative). Removed when the calling fiber is disposed. Emits - * `system-prompt/change` on register/unregister. + * Contribute a text section to the system prompt. + * * @param section - the section to contribute (name, order, text or provider). * @returns the disposer that removes the section. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -481,26 +371,15 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.section()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } /** - * Contribute a tool-schema provider, evaluated at each assembly call with - * that assembly's {@link AssembleContext} (so it reflects the live registry - * state AND the assembly's scope — see {@link ToolProviderResult} for the - * `schemas`/`knownNames` split). The layer is decided by the calling - * context: a scoped provider (registered through `agent.ctx`) is consulted - * only for that scope's assemblies. Removed when the calling fiber is - * disposed. A provider must not return a schema named - * {@link TOOL_ORDER_REST}; that name is reserved for - * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits - * `system-prompt/change`. + * Contribute a tool-schema provider, evaluated at each assembly call with that assembly's + * {@link AssembleContext} (so it reflects the live registry state AND the assembly's scope — + * see {@link ToolProviderResult} for the `schemas`/`knownNames` split). + * * @param provider - evaluated at every {@link assemble} for fresh schemas. * @returns the disposer that removes the provider. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -527,27 +406,13 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.tools()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } /** - * Contribute a named prompt variable, referenced from section text as - * `{{name}}`. The provider is evaluated at each assembly with that - * assembly's {@link AssembleContext}; returning `undefined` means "no value - * for this assembly" (a section referencing it then fails to render — a - * deployment must not claim facts it does not have). The layer is decided - * by the calling context: a scoped variable (registered through - * `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a - * same-named global variable there. Throws on a name that does not match - * `[a-z][a-z0-9_]*` (it could never be referenced) or one already - * registered in the SAME layer. Removed when the calling fiber is disposed; - * emits `system-prompt/change` on register/unregister. + * Contribute a named prompt variable, referenced from section text as `{{name}}`. + * * @param name - the reference name (matches `[a-z][a-z0-9_]*`). * @param provider - evaluated at every {@link assemble} for the value. * @returns the disposer that removes the variable. The exact @@ -581,29 +446,13 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.variable()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } /** * Protect named section/tool contributions from the assembly waterfall. - * The layer is decided by the calling context: a global protection applies - * to every assembly, while one registered through `agent.ctx` applies only - * to that agent's scope. The name's canonical registry/provider output is - * restored AFTER the whole waterfall, so listener registration order cannot - * strip, replace, duplicate, or fabricate it. Canonical absence is restored - * too: if the protected name is intentionally absent for an assembly, a - * listener-injected entry with that name is removed. The input arrays are - * snapshotted; an empty protection throws because it cannot affect output. - * Removed with the calling fiber and emits `system-prompt/change` on - * registration/unregistration. A global section protection also reserves the - * name against scoped section shadows; registering protection when such a - * shadow already exists fails loudly instead of protecting the wrong owner. + * * @param protection - section and/or tool names whose canonical presence and definitions are authoritative. * @returns the exact Cordis effect disposer that removes the protection. */ @@ -658,41 +507,16 @@ export class SystemPrompt extends Service { } /** - * Assemble the current prompt for one caller: the global layer merged with - * {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW - * same-named global ones — most-specific-wins) — section texts resolved - * against `context` and sorted by order across the union, tools collected - * from the global providers plus the scope's and put in the canonical - * model-facing order ({@link Config.toolOrder}, or lexicographic name order - * when unconfigured — provider registration order is a plugin-load artifact - * and never reaches the assembly; a configured order naming a tool outside - * the providers' `knownNames` universe rejects the assembly, while a known - * name restricted away for this scope is a normal absence), and every - * visible variable resolved against `context` into `assembly.variables`. - * Tool schemas are deep-cloned because adapters and request waterfalls may - * mutate schema objects. Runs through the `system-prompt/assemble` - * waterfall, giving listeners the opportunity to mutate or replace the - * assembly, then restores every visible {@link PromptProtection} from the - * pre-waterfall canonical assembly. Like the sections' `order` sort, tool - * canonicalization happens on the initial assembly; unprotected listener - * output owns its own determinism. Await the result before reading the - * assembly values — waterfall listeners may be async. - * Interpolation happens later, in {@link renderPrompt}. - * @param context - what this assembly is for (defaults to an empty context; - * see {@link AssembleContext}). + * Assemble global contributions with one scope, then run the assembly waterfall and protection. + * @param context - assembly subject and scope; defaults to an empty context. * @returns the assembly after the waterfall has run. */ - // async so the misconfigured-toolOrder throw in orderTools surfaces as a - // rejection: a Promise-returning method must not throw synchronously - // (`assemble().catch(...)` would miss it). + // Async ensures validation failures are promise rejections. async assemble(context: AssembleContext = {}): Promise { const scope = context.scope - // Protection is a registry input too: snapshot which names are protected - // at assembly start. Registrations that land while an async waterfall is - // in flight affect the NEXT assembly, matching the other registries. + // Registrations arriving mid-assembly affect the next assembly. const protectedNames = this.protectedNames(scope) - // Variables: global layer first, then the scope's layer OVERWRITES - // same-named entries (shadowing — a per-agent value wins for that agent). + // Scoped variables shadow global names. const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) @@ -701,21 +525,13 @@ export class SystemPrompt extends Service { for (const [name, provider] of scopedVariables ?? []) { variables[name] = provider(context) } - // Sections: merge by name, scoped REPLACING same-named global entries - // (most-specific-wins — the per-agent persona mechanism), then sort by - // order across the union. Registration order within a layer is preserved - // for equal orders (stable sort). + // Scoped sections shadow global names before the stable order sort. const sectionByName = new Map() for (const section of this.sections) sectionByName.set(section.name, section) for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) { sectionByName.set(section.name, section) } - // Tools: consult the global providers plus the scope's, each with this - // assembly's context. `schemas` are what the model may see (already - // post-restriction, per provider); `knownNames` (defaulting to the - // schemas' names) form the pre-restriction universe `toolOrder` is - // validated against, so a restricted-away tool is a normal absence while - // a config typo still fails every assembly loudly. + // `knownNames` validates order before per-scope restrictions hide schemas. const providers = [ ...this.toolProviders, ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 323fbeed2b..8fce79f13f 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -1,15 +1,5 @@ /** - * Code Mode: the `run_code` tool and its dispatch bridge. The model writes a - * TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async - * binding per end capability visible to the calling agent, then serializes - * every binding call through a per-run queue onto `ToolRegistry.execute()`. - * Sub-calls therefore traverse the complete pre/guard/around/post/final-result - * pipeline exactly like native calls and carry the outer execution's opaque - * token for correlation. The bridge logs each sub-dispatch as a - * `tool/code-dispatch` session event and returns only the program's curated - * output. The registry itself decides WHEN this tool exists (its `mode` - * config); this module owns only the tool and the bridge. - * + * Code Mode: the `run_code` tool and its dispatch bridge. * @module @deepseek-ai/dsh-tools/src/code-mode */ @@ -24,16 +14,11 @@ import type { ToolDefinition, ToolRegistry } from './index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * One bridged sub-dispatch from a `run_code` program: the parent - * `run_code` call id, the deterministic sub-call id - * (`:code:`), 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 (`:code:`), 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. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } } @@ -89,16 +74,11 @@ function summarize(text: string): string { } /** - * JSON-normalize one binding call's argument into TWO independent parses of - * the same canonical text: `dispatched` goes to the tool, `logged` to the - * `tool/code-dispatch` event — identical by construction (the runtime's - * structured-clone boundary is wider than JSON; the session log accepts only - * JSON), and separate objects, so a tool mutating its args can neither - * desync the log from what was dispatched nor re-poison the append. A value - * that does not survive the round-trip (`undefined` — the log rejects it as - * event data — `BigInt`, a circular structure, a bare function) rejects that - * one call BEFORE dispatch with a model-correctable error: nothing ever - * executes unlogged. + * JSON-normalize one binding call's argument into TWO independent parses of the same canonical + * text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical + * by construction (the runtime's structured-clone boundary is wider than JSON; the session log + * accepts only JSON), and separate objects, so a tool mutating its args can neither desync the + * log from what was dispatched nor re-poison the append. */ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } { if (value === undefined) { @@ -172,11 +152,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.signal?.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 - // The per-run serialization queue: every binding call chains onto the - // tail, so even `Promise.all` executes the underlying tool calls one at - // a time in submission order (the tool contract carries no - // concurrency-safety metadata yet). The fold keeps the tail non-rejecting - // so one failed dispatch never poisons the chain. + // The per-run serialization queue: every binding call chains onto the tail, so even + // `Promise.all` executes the underlying tool calls one at a time in submission order (the + // tool contract carries no concurrency-safety metadata yet). let queue: Promise = Promise.resolve() const enqueue = (task: () => Promise): Promise => { const turn = queue.then(() => { @@ -211,11 +189,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => signal: runController.signal, }) const text = textOf(result.content) - // Sub-call `additionalContext` is deliberately DROPPED here: the - // loop's buffering (append after the step's tool/results) has no - // safe analogue from inside a running run_code — injecting now - // would break tool-call/result adjacency. Deferred until a real - // hook needs it through Code Mode. + // Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering + // (append after the step's tool/results) has no safe analogue from inside a running + // run_code — injecting now would break tool-call/result adjacency. exec.agent?.session.append('tool/code-dispatch', { parentCallId: exec.callId, subCallId, @@ -266,18 +242,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => signal: runController.signal, }) } finally { - // Quiescence before returning, whether the runtime fulfilled or - // REJECTED (a backend that starts a binding call and then throws - // must not leak a live sub-dispatch past this settlement): fire - // the run-scoped abort (cancelling an in-flight sub-dispatch, - // abandoning queued ones), then await the queue's drain — an - // aborted sub-call still settles and logs its event INSIDE the - // open turn; nothing can append after we return. `queue` is the - // FOLDED tail (every link swallows its rejection into undefined), - // so this await cannot itself reject — an abandoned queued call - // can never mask the runtime's own failure, returned or thrown; - // rejections surface only on the per-call promises the program - // holds. + // Abort sub-dispatches and drain the folded queue before closing the turn. + // Binding failures remain observable through their individual promises. runController.abort('run_code settled') await queue } @@ -297,13 +263,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.signal?.removeEventListener('abort', onOuterAbort) } }, - // The program IS the title, the way command tools title their cards with - // the command: an execute-card's title is the one slot an ACP client - // always shows (Zed's execute cards render no body content and no raw - // input without a real terminal attached), so anywhere else the code - // would be invisible. Multi-line titles are the execute-card idiom — - // capable clients render them whole; others truncate to the first line - // and still hold the full program in rawInput. + // ACP execute cards use the program as their visible title. presentCall: args => ({ card: 'generic', title: args.code, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 71dd465dd6..9f9e1b8739 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,18 +1,10 @@ /** - * Tool registry and execution pipeline. Plugins register tools; the registry - * feeds schemas into the system prompt, and `execute()` dispatches each call - * through `tools/pre-execute` (the extensible allow/deny gate) → monotonic - * registered guards → `tools/execute` (an around-dispatch wrapper for - * timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the - * result, attach context) → the observe-only `tools/result` notification. - * - * The registry also owns HOW its tools are presented to the model — its - * `mode` config: `'native'` (every tool as a wire function definition, - * today's behavior and the default), `'code'` (the registry's canonical wire - * contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or - * `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and - * `ts-types.ts` (the SDK codegen); design in the Code Mode RFC. - * + * Tool registry and execution pipeline. Plugins register tools; the registry feeds schemas + * into the system prompt, and `execute()` dispatches each call through `tools/pre-execute` + * (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an + * around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` + * (inspect/replace the result, attach context) → the observe-only `tools/result` notification. + * Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners. * @module @deepseek-ai/dsh-tools */ @@ -83,82 +75,39 @@ declare module 'cordis' { interface Events { /** - * 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 {@link 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 - * {@link 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`). + * * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise /** - * 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 {@link 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. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise /** - * 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 {@link 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. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise /** - * 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 - * {@link 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. * @param exec - the execution object that traversed the pipeline. * @param result - a deep-frozen snapshot of the final returned result. * @mode parallel @@ -335,38 +284,14 @@ export interface ToolExecutionResult { meta?: unknown } -/** - * The decision a `tools/pre-execute` listener returns for one pending call. - * Maps onto Claude Code's `PreToolUse` `permissionDecision`. - * - * - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` — - * is deliberately NOT offered: `tool/call` and `assistant/message` are logged - * BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash` - * presentation, read the pre-execution arguments, so an execution-only rewrite - * would desync the UI from what RAN. That consistency redesign is its own - * `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.) - * - `deny` skips dispatch; the loop records an `isError` result carrying `reason`. - * - `ask` is the permission-prompt intent: serviced as a one-shot decision by - * the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to - * dispatch; every other outcome denies), degrading to `deny` when none is. - */ +/** Pre-execution decision: dispatch, deny with a reason, or ask the approval seam. */ +// TODO(pre-tool-input-rewrite): design logged argument rewriting before exposing it here. export type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string } -/** - * The decision a `tools/post-execute` listener returns for one finished call. - * Maps onto Claude Code's `PostToolUse` decision. - * - * - `accept` keeps the call successful; optional `content` REPLACES the - * model-facing result (clean: `tool/result` is logged AFTER `execute()` - * returns, so a replaced result is the single source of truth for both derived - * history and UI). Optional `additionalContext` rides to the next request. - * - `block` turns the call into an `isError` result whose content is the - * corrective `feedback` (the model is told the call was rejected and why), - * optionally also attaching `additionalContext`. - */ +/** Post-execution decision: accept optional replacement content or block with feedback. */ export type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } @@ -408,32 +333,16 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** 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 } /** - * A per-scope restriction over the GLOBAL tool surface, registered via - * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools; - * `deny` removes the listed ones; both present = allow first, then deny. - * Restrictions never touch scoped registrations — a tool registered through - * the same scope is an explicit grant that bypasses them (which is what keeps - * e.g. a structured-output capture tool alive under an allow-list). The - * reserved `run_code` presentation transport is likewise outside capability - * filtering, and naming it explicitly is rejected. Multiple restrictions on - * one scope compose by intersection: every one must admit. + * A per-scope restriction over the global tool surface, registered via {@link + * ToolRegistry.restrict}. `allow` keeps only the listed global tools; `deny` removes the + * listed ones; both present = allow first, then deny. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -458,25 +367,9 @@ interface ToolGuardRegistration { } /** - * 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 {@link 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). {@link restrict} masks the global layer per - * scope. One visibility function ({@link visible}) feeds prompt assembly, - * {@link get}, and {@link 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. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] @@ -501,11 +394,7 @@ export class ToolRegistry extends Service { // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. this.mode = config.mode ?? 'native' - // `run_code` is presentation infrastructure, not an end capability. It - // therefore does not enter the global layer: per-agent restrictions must - // not remove it, and a scoped registration must not shadow it. The - // visibility resolver appends this reserved definition after resolving - // the filterable global/scoped capability layers. + // `run_code` is presentation infrastructure, not an end capability. this.codeTransport = this.mode === 'native' ? undefined : deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime())) @@ -514,43 +403,20 @@ export class ToolRegistry extends Service { ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, - // A lazy thunk over the live registry, per assembly CONTEXT: - // regenerated at each assembly over the CALLING SCOPE's visible set - // (scoped tools join, restricted globals vanish — the SDK declares - // exactly what that agent's programs can call), in lexicographic - // tool order, so an unchanged tool set renders byte-identical text - // (prefix-cache-friendly) and a mid-session registration surfaces - // exactly like a native-mode tool change. + // Regenerate the scoped tool SDK on every assembly in stable lexical order. text: (context) => { this.requireCodeRuntime() return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME)) }, }) // These are presentation infrastructure, not optional end capabilities. - // Protect them at their owner: assembly listeners may still transform - // ordinary tools and prose, but cannot silently leave Code Mode without - // its only wire transport or the SDK that tells the model how to use it. ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] }) } } /** - * The registry's contribution to the wire tool list, per {@link Config.mode}, - * as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions - * applied — {@link schemas}). Because `PromptAssembly.tools` is what the - * loop's request header snapshots, the mode's collapse is logged and - * reconstructable for free. Under a non-native mode this is also the loud - * misconfiguration gate: no usable code runtime → every assembly rejects - * before any model request. - * - * The `knownNames` universe distinguishes the two ways a tool can be off - * the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays - * pre-restriction and a restricted-away tool in `toolOrder` is a normal - * absence — while the MODE collapse is deployment config, so under - * `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a - * native tool is dead configuration that fails every assembly loud. Under - * `mode: 'both'`, the provider adds the reserved transport to the - * capability-only {@link knownNames} universe for `toolOrder` validation. + * The registry's contribution to the wire tool list, per {@link Config.mode}, as one SCOPE + * sees it (scoped layer joins, shadowing and restrictions applied — {@link schemas}). */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) } @@ -582,20 +448,8 @@ export class ToolRegistry extends Service { } /** - * Register a tool. The layer is decided by the CALLING context: a plain - * plugin context registers globally; a scoped context (`agent.ctx`) - * registers into that scope's layer — visible to that agent alone, disposed - * with the scope, and shadowing a same-named global tool for that agent. - * Throws if the SAME layer already has the name (cross-layer name twins are - * the shadowing feature, not an error; the global-duplicate message names - * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves - * the `run_code` name for its presentation transport. The visible schema set - * flows into prompt assembly automatically. Registration validates and - * clones the JSON parameters, copies scalar fields, binds each callback once - * to the caller's definition as its method receiver, and freezes the stored - * snapshot; later mutation or callback replacement on the input object does - * not rewrite the registry. Disposed with the calling fiber. Emits - * `tools/change` on register/unregister. + * Register a tool. + * * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -605,11 +459,6 @@ export class ToolRegistry extends Service { register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) // A schema crosses the same model/log boundary as execution arguments. - // Validate BEFORE cloning because structuredClone silently turns some - // forbidden values (for example class instances) into plain records, then - // validate the detached value again to contain hostile getters that change - // between inspection and snapshotting. A frozen Map is still mutable, so - // deepFreeze alone is not a sufficient registration boundary. if (!isJsonValue(definition.parameters)) { throw new TypeError('tool parameters must be losslessly JSON-serializable') } @@ -643,11 +492,10 @@ export class ToolRegistry extends Service { : `tool "${snapshot.name}" is already registered in this scope`) } layer.set(snapshot.name, snapshot) - // Yield the rollback BEFORE emitting `tools/change`: a generator effect - // collects each yielded disposer before the next step runs, so a throwing - // `tools/change` listener removes the tool instead of leaking it (a leak - // would wedge the duplicate-name check until restart). The duplicate - // throw above fires before any mutation — it leaks nothing. + // Yield the rollback before emitting `tools/change`: a generator effect collects each + // yielded disposer before the next step runs, so a throwing `tools/change` listener + // removes the tool instead of leaking it (a leak would wedge the duplicate-name check + // until restart). yield () => { layer.delete(snapshot.name) // An emptied scope layer is dropped so a disposed scope leaves no @@ -657,31 +505,13 @@ export class ToolRegistry extends Service { } this.ctx.emit('tools/change') }.bind(this), 'tools.register()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } /** - * Restrict the GLOBAL tool surface for the calling scope. Must be called - * through a scoped context (`agent.ctx`) — restricting "everyone" is not a - * thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op - * that can only be a bug (throw — the materialized-empty-config trap). - * Validates every listed name against the scope's CURRENT pre-restriction - * name universe ({@link knownNames}) and throws on an unknown one (fail loud - * beats a typo silently filtering nothing) — register restrictions after the - * global tools they mask exist (the agent-creation `setup` window satisfies - * this). A non-native mode's reserved `run_code` presentation transport is - * not a filterable capability; naming it explicitly throws, while omitting - * it from an allow-list cannot remove it. The filter is SNAPSHOT at - * registration: later caller mutation of the arrays changes nothing. - * Multiple restrictions compose by intersection. Scoped registrations - * bypass restrictions (explicit grants win). Disposed with the calling - * fiber (revocable independently); emits `tools/change`. + * Restrict the global tool surface for the calling scope. + * * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). * @returns the disposer that lifts this restriction. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -722,12 +552,7 @@ export class ToolRegistry extends Service { } this.ctx.emit('tools/change') }.bind(this), 'tools.restrict()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } @@ -856,14 +681,9 @@ export class ToolRegistry extends Service { } /** - * The model-facing schemas of everything `scope` can see — exactly the - * fields (`name`, `description`, `parameters`) sent to the model via the - * system-prompt assembly. Constructed EXPLICITLY rather than by stripping - * known non-schema members: a `ToolDefinition` also carries `execute` and the - * optional `presentCall`/`presentResult` UI callbacks, and those (especially - * the functions) must never leak into a model request. An allowlist can't - * drift when a new non-schema member is added to the definition; a denylist - * (rest-destructure) would silently leak it. + * The model-facing schemas of everything `scope` can see — exactly the fields (`name`, + * `description`, `parameters`) sent to the model via the system-prompt assembly. + * * @param scope - the viewing scope (the agent); omitted = the global view. * @returns one deep-cloned schema per visible tool. */ @@ -895,26 +715,12 @@ export class ToolRegistry extends Service { } /** - * Execute one tool call through the `tools/pre-execute` → guards → - * `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result` - * pipeline. `pre-execute` is the extensible gate - * (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics - * seam), and `post-execute` is the inspect/transform seam; core dispatch sits - * as the base `next()` of the `tools/execute` waterfall. The whole thing is - * wrapped in one outer try/catch so a throwing listener (in any waterfall) - * becomes an `isError` result instead of failing the turn; the tool body ALSO - * keeps its own inner try/catch, so a thrown tool becomes an `isError` result - * that `tools/execute` and `post-execute` listeners can still inspect. If the - * tool is not registered (or not visible to the calling agent — a - * restricted-away global is exactly as absent as a nonexistent one), the - * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown - * {@link HarnessError} surfaces its `{ name, code }` on the result. Before - * the final observe-only notification, the authoritative outcome must survive - * a lossless JSON round trip; an invalid outcome is normalized to an error. - * A malformed runtime/casted `tools/pre-execute` decision likewise normalizes - * to an error before approval, guards, or the tool body. - * Caller-owned arguments must survive lossless-JSON validation before and - * after cloning; a violation normalizes to an error before policy or dispatch. + * Execute one tool call through the `tools/pre-execute` → guards → `tools/execute` (around + * dispatch) → `tools/post-execute` → `tools/result` pipeline. `pre-execute` is the + * extensible gate (allow/deny/ask), `tools/execute` wraps core dispatch (a + * timeout/retry/metrics seam), and `post-execute` is the inspect/transform seam; core + * dispatch sits as the base `next()` of the `tools/execute` waterfall. + * * @param exec - the single-use call input; its identity is snapshotted and * protected before policy runs. * @returns the final result after every waterfall; failures resolve as @@ -925,11 +731,8 @@ export class ToolRegistry extends Service { try { execution = this.prepareExecution(exec) } catch (error: unknown) { - // Contract-violating non-JSON or non-cloneable arguments cannot enter a - // pipeline whose logged and executed forms must agree. Still publish one - // scoped final outcome, using an immutable identity shell, so result - // observers retain their every-call guarantee without seeing the invalid - // value. + // Contract-violating non-JSON or non-cloneable arguments cannot enter a pipeline whose + // logged and executed forms must agree. execution = Object.freeze({ token: createExecutionToken(), callId: exec.callId, @@ -945,11 +748,8 @@ export class ToolRegistry extends Service { } let result: ToolExecutionResult try { - // Validate the authoritative FINAL result, not merely the tool body's - // intermediate return. Post-policy may replace content or attach context, - // and every one of these fields is session-bound. Reject anything that - // cannot round-trip losslessly through the durable JSON log before the - // observe-only `tools/result` commit point sees success. + // Validate the authoritative final result, not merely the tool body's intermediate + // return. result = this.snapshotExecutionResult(execution, await this.executePipeline(execution)) } catch (error: unknown) { // Outer backstop: a throwing pre/post-execute listener, guard, or the @@ -1002,10 +802,7 @@ export class ToolRegistry extends Service { /** Run the transformable pipeline; {@link execute} owns final normalization and notification. */ private async executePipeline(exec: ToolExecution): Promise { - // --- Gate: tools/pre-execute. An `ask` resolves through the optional - // approval seam (or degrades to deny) before the monotonic guards run. The - // carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only - // its own agent's calls (agent-less calls are subject-less). + // --- Gate: tools/pre-execute. const carrier = scopeTarget(this, exec.agent) const gate = this.snapshotPreDecision(await this.ctx.waterfall( carrier, 'tools/pre-execute', exec, @@ -1026,14 +823,7 @@ export class ToolRegistry extends Service { return await this.postExecute(exec, denied) } - // --- Around-dispatch: tools/execute. The base `next` is the dispatch- - // with-normalization thunk — the tool body's own try/catch turns a throw - // into an isError result so a wrapper (and post-execute) can inspect it; - // an unknown tool routes through the same catch. A `tools/execute` listener - // (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal` - // before delegating and inspect the normalized result after. Dispatched with the - // same carrier as the gate, so an `agent.ctx` wrapper wraps only its own - // agent's calls. --- + // --- Around-dispatch: tools/execute. const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall( carrier, 'tools/execute', exec, async (): Promise => { @@ -1117,15 +907,7 @@ export class ToolRegistry extends Service { } /** - * Resolve an `ask` decision to allow/deny through the approval seam. The - * seam is consumed opportunistically with `ctx.get('approval')` — a - * deployment that composes no ApprovalService keeps the historical degrade - * to deny, and an unmount mid-session degrades the same way on the next ask. - * An agent-less execution also degrades: without an agent there is no - * session to audit to and no UI to route to. Otherwise the outcome maps - * one-to-one — `allowed-once` proceeds; the three non-grants deny with - * distinct reasons so the model can tell a human "no" from an absent - * approval channel. + * Resolve an `ask` decision to allow/deny through the approval seam. */ private async serviceAsk( exec: ToolExecution, @@ -1163,14 +945,7 @@ export class ToolRegistry extends Service { * Runs inside `execute`'s outer try/catch (a throwing listener → isError). */ private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { - // Snapshot the protected outcome BEFORE the waterfall. A listener receives - // the same `result` reference, so a post-waterfall read of `result.callId`/ - // `.isError`/`.error` could carry a listener's mutation — violating the - // authoritative-call-id requirement and the "preserve the dispatched - // isError/error" contract. The decision is the ONLY sanctioned channel for a - // listener to change the outcome (block, or accept-with-replacement); the - // call id is always the authoritative `exec.callId`. Deep cloning protects - // nested content, error, and meta data from in-place listener mutation. + // Snapshot the protected outcome before the waterfall. const dispatched = this.snapshotExecutionResult(exec, result) const decision = structuredClone(await this.ctx.waterfall( scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, @@ -1214,10 +989,8 @@ export class ToolRegistry extends Service { ...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {}, ...result.meta !== undefined ? { meta: result.meta } : {}, } - // Validate BEFORE cloning: structuredClone turns some forbidden exotic or - // class instances into plain objects, which would hide a lossy JSON - // boundary violation. Validate the detached clone again to contain hostile - // getters whose value changes between inspection and snapshotting. + // Validate before cloning: structuredClone turns some forbidden exotic or class instances + // into plain objects, which would hide a lossy JSON boundary violation. if (!isJsonValue(candidate)) { throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult') } diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 4c1036773b..3a8284fa59 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,31 +1,7 @@ /** - * Structured-output JSON Schema subset: the vocabulary a caller uses to demand - * a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) - * or a workflow `agent()` call. - * - * This is deliberately NOT full JSON Schema. The schema travels verbatim to the - * model as a forced tool's `parameters`, and the value the model produces is - * validated here — so every accepted keyword must be one this module actually - * enforces. Accepting a keyword we don't enforce would validate less than the - * schema promises (accepted-then-ignored), so anything outside the subset is - * REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset: - * - * - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/ - * `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected. - * - `properties`/`required`/`additionalProperties` (boolean) on objects; every - * `required` key must be declared in `properties`. `additionalProperties` - * absent keeps standard JSON Schema semantics (extra keys allowed). - * - `items` on arrays (absent ⇒ any JSON items). - * - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types. - * - Annotations `description`/`title`/`default`/`examples` are allowed and - * ignored (they constrain nothing), except that they must still be JSON data - * — the schema is serialized onto the wire, so a non-JSON annotation would be - * silently mangled. - * - * Values checked by {@link validateStructuredValue} are expected to be plain - * host-realm JSON data (model tool-call arguments are parsed wire JSON; a - * caller holding foreign-realm data materializes it first). - * + * Structured-output JSON Schema subset: the vocabulary a caller uses to demand a + * machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) or a workflow + * `agent()` call. * @module dsh-tools/json-schema */ diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index b99fa08ebd..1499f9eecb 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -1,20 +1,7 @@ /** * Tool render-intent vocabulary: the provider-neutral types a tool declares via - * `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say - * how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log - * line). A UI bridge switches on the `card` tag to map each intent to its own - * wire shape, so a UI never special-cases tool names. - * - * This is the UI-facing surface of `dsh-tools`, kept separate from the registry - * and execution core in `index.ts`: this module owns ONLY presentation - * vocabulary and references none of the execution types, so the dependency runs - * one way (`index.ts` imports these views for the `ToolDefinition` method - * signatures). The opaque `meta` presentation channel is execution plumbing and - * lives with the registry in `index.ts`, not here. - * - * See the render-intent-union RFC - * (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). - * + * `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say how one of its calls + * renders in a UI (an editor's tool-call card, a CLI log line). * @module @deepseek-ai/dsh-tools/src/presentation */ @@ -186,16 +173,8 @@ export interface TerminalResultView { } /** - * A completed file mutation rendered as an inline diff card, the *result-time* - * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file - * change (e.g. `write`, `edit`): `diffs` are the change to show — typically the - * APPLIED hunks computed from the before/after content (one entry per hunk, each - * with surrounding context lines), so the editor shows the real change in place; - * a tool with no before-image (e.g. a file create) may instead give a whole-file - * diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's - * content in an editor, so a mutation tool returns this even when it duplicates - * the call-time snippet — otherwise the model-facing result text would replace - * (clobber) the pending diff card. + * A completed file mutation rendered as an inline diff card, the *result-time* analogue of + * {@link DiffCallView}. */ export interface DiffResultView { card: 'diff' diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 1a428ffd40..443d01ae0a 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,21 +1,5 @@ /** * Typed tool-parameter schema DSL. - * - * Plugin authors write per-property specs with `required: true` as a boolean - * (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec - * to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a - * SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`, - * `required` array) for the wire format sent to the model. - * - * # Why a custom DSL and not schemastery? - * - * Schemastery is a validation/transformation library (StandardSchema v1) used - * for plugin Config. Tool parameters need JSON Schema specifically (the LLM - * wire format), not validation. A lightweight DSL focused on JSON Schema - * generation, with type inference for the tool's `execute` args, gives plugin - * authors the best DX with the smallest surface area. Schemastery would add - * unnecessary indirection and wouldn't cleanly produce JSON Schema. - * * @module dsh-tools/schema */ @@ -263,15 +247,10 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] { } /** - * Validate model-generated `args` against a {@link SchemaSpec}, returning a - * list of human-readable violation messages (empty = valid). Total — never - * throws, regardless of how malformed `args` is. + * Validate model-generated `args` against a {@link SchemaSpec}, returning a list of + * human-readable violation messages (empty = valid). Total — never throws, regardless of how + * malformed `args` is. * - * Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must - * be a non-array object; required keys come only from `required: true`; extra - * keys are allowed (no `additionalProperties: false`); `default` is not - * applied; an `object`/`array` prop without `properties`/`items` only - * type-checks; `enum` is membership (strings only). * @param spec - the declared parameter schema to validate against. * @param args - the model-generated arguments, however malformed. * @returns the violation messages in declaration order; empty means valid. @@ -330,29 +309,6 @@ export interface DefineToolOptions { /** * Define a tool with a typed parameter schema. * - * Use this instead of constructing a raw {@link ToolDefinition} for all - * first-party tools. The `parameters` use the boolean-required style - * (`required: true` as a per-property flag), and `execute` receives typed - * args derived from the schema. - * - * ```ts - * const tool = defineTool({ - * name: 'read_file', - * description: 'Read a file from disk.', - * parameters: { - * path: { type: 'string', required: true, description: 'Absolute file path' }, - * offset: { type: 'number' }, - * limit: { type: 'number', description: 'Max lines to read' }, - * }, - * async execute(args) { - * // args: { path: string; offset?: number; limit?: number } - * }, - * }) - * ``` - * - * Raw JSON-Schema tool definitions (from MCP servers) are still accepted - * by `ToolRegistry.register()` directly — `defineTool` is sugar for - * first-party plugin authors. * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the @@ -378,10 +334,7 @@ export function defineTool(options: DefineToolOptions): parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), async execute(args: unknown, exec: ToolExecution): Promise { - // Validate the model-generated args before the typed body runs. On - // mismatch we throw ToolArgsError; the registry turns it into an - // isError result so the model can self-correct. After this guard, the - // cast to InferArgs reflects the validated shape. + // Validate the model-generated args before the typed body runs. const violations = validateArgs(options.parameters, args) if (violations.length > 0) throw new ToolArgsError(violations) return userExecute(args as InferArgs, exec) diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 63ebd0f888..f93d952aad 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -1,17 +1,8 @@ /** - * Code Mode codegen: the pure projection from registered tool schemas to the - * TypeScript SDK text the model programs against (the `tools:sdk` prompt - * section). Sibling of `json-schema.ts` — `schemas()` (native function - * calling) and this module (the generated `declare const tools` surface) are - * two projections of the same store. - * - * TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the - * `defineTool` DSL emits and degrades every construct outside it (`$ref`, - * `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever - * throwing — codegen must never be the thing that fails an assembly. - * Deterministic: a fixed tool set renders byte-identical text (tools in - * lexicographic name order), so the section is prefix-cache-friendly. - * + * Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK + * text the model programs against (the `tools:sdk` prompt section). Sibling of + * `json-schema.ts` — `schemas()` (native function calling) and this module (the generated + * `declare const tools` surface) are two projections of the same store. * @module @deepseek-ai/dsh-tools/src/ts-types */ @@ -33,10 +24,8 @@ function pad(indent: number): string { /** A one-line JSDoc block for a schema `description`, or no lines when there is none. */ function docLines(description: unknown, indent: number): string[] { if (typeof description !== 'string' || description.length === 0) return [] - // Keep the doc a single-line comment per property: descriptions are prose - // (possibly with newlines); collapse whitespace so the rendered SDK stays - // stable and compact. A comment-closer inside the description is escaped so - // it cannot terminate the generated JSDoc early. + // Keep the doc a single-line comment per property: descriptions are prose (possibly with + // newlines); collapse whitespace so the rendered SDK stays stable and compact. const collapsed = description.replace(/\s+/g, ' ').trim() return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`] } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index da5725f10e..02a536a918 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -358,10 +358,8 @@ describe('the run_code dispatch bridge', () => { return { logs: [], value: 'done' } } - // Model a timeout-style outer wrapper: it temporarily installs a signal, - // delegates, then restores the exact prior shape. A nested result observer - // is observe-only and must not receive the live outer execution object; - // freezing the correlation value it sees therefore cannot break restore. + // Model a timeout-style outer wrapper: it temporarily installs a signal, delegates, then + // restores the exact prior shape. ctx.on('tools/execute', async (exec, next) => { if (exec.name !== RUN_CODE_NAME) return next() const previous = exec.signal @@ -585,11 +583,8 @@ describe('the run_code dispatch bridge', () => { }, })) runtime.behavior = async (request) => { - // Start a sub-dispatch, keep its rejection held, and fail the run once - // the tool is genuinely in flight — a seam error AFTER work has begun. - // The bridge's settlement still owes quiescence: without the finally, - // run_code would return now and the slow tool would finish (and log) - // afterwards. + // Start a sub-dispatch, keep its rejection held, and fail the run once the tool is + // genuinely in flight — a seam error after work has begun. request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held') await inFlight throw new Error('backend exploded') diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8bab060a5d..ad739173c9 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -1,17 +1,5 @@ /** - * Guarantee tests for the tool-schema catalog generator - * (`scripts/gen-tool-catalog.ts`). - * - * The generated catalog is frozen by a regenerate-and-diff freshness gate, so - * the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What - * a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the - * shipped schema — the whole reason this generator boots instead of parsing - * source (a runtime-spread enum resolves to its literal members) — and (b) that - * the completeness guard REJECTS a tool package missing from the boot manifest, - * the property that replaces the AST pass's "nothing silently omitted". These - * tests drive the exported `collectToolCatalog` / `assertManifestComplete` / - * `render` directly, mirroring the negative-path style of the cordis-catalog - * generator tests. + * Guarantee tests for the tool-schema catalog generator (`scripts/gen-tool-catalog.ts`). */ import { describe, expect, it } from 'vitest' @@ -62,11 +50,8 @@ describe('gen-tool-catalog collectToolCatalog', () => { }) it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => { - // `tool-subagent`'s registered name is the load-time `toolName` config, so - // the shipped agents surface this one package as both `subagent` and - // `subagent_fork`. Booting yields only the default name; the note is how a - // reader learns the fork alias the model also sees. Without it the catalog - // would silently under-report the shipped tool surface. + // `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped + // agents surface this one package as both `subagent` and `subagent_fork`. const catalog = await collectToolCatalog() const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent') expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent']) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index deb53b7dc7..962b08d051 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -485,11 +485,7 @@ describe('ToolRegistry', () => { }) it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => { - // The decision is the ONLY sanctioned channel to change the outcome. A - // listener that reaches in and mutates the passed result reference (flipping - // isError, rewriting callId, attaching a bogus error) must NOT affect what - // execute() returns — the registry snapshots the authoritative fields before - // the waterfall and rebuilds from the snapshot + decision. + // The decision is the only sanctioned channel to change the outcome. const ctx = await setup() ctx.tools.register(echoTool) ctx.on('tools/execute', async (_exec, next) => { @@ -972,16 +968,9 @@ describe('ToolRegistry', () => { }) it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => { - // The registry-disposer convention (set by agents.register): the returned - // function IS the cordis effect disposer, so a composite (generator) - // effect that yields it has the unregistration run at that yield's LIFO - // position on owner unload. A wrapper would leave the inner effect - // disposing as a CONCURRENT SIBLING of the composite; the async probe - // below (disposed first, LIFO) yields the event loop exactly like the - // agent factory's stop-and-drain link, and a sibling unregistration fires - // in that window — the probe would observe the tool already gone. Pins - // the convention for the whole register-method family (system-prompt - // registrars, registerProvider, setFactory share the same return). + // The registry-disposer convention (set by agents.register): the returned function IS the + // cordis effect disposer, so a composite (generator) effect that yields it has the + // unregistration run at that yield's LIFO position on owner unload. const ctx = await setup() const order: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { @@ -1681,10 +1670,9 @@ describe('defineTool presentation (presentCall / presentResult)', () => { presentCall: args => ({ card: 'generic', title: args.path }), presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }), }) - // Unlike execute (which throws ToolArgsError on a mismatch), the display - // methods soft-validate and fall back to undefined so a UI never crashes - // replaying an old/foreign log entry. The ToolDefinition methods take - // `unknown`, so malformed shapes pass without a cast. + // Unlike execute (which throws ToolArgsError on a mismatch), the display methods + // soft-validate and fall back to undefined so a UI never crashes replaying an old/foreign + // log entry. expect(tool.presentCall?.({})).toBeUndefined() expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined() }) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index f9af5375d2..2af10d5101 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -1,20 +1,7 @@ /** - * Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept - * separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so - * the raw stat/read/write/edit mechanics can be unit-tested without a Context. - * - * This is the PROVIDER layer: it hands back decoded whole-file text (validated - * UTF-8, binary rejected) — never line windows or numbered lines, which are - * model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files - * stream their text in chunks so a huge file never has to be held whole in - * memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here. - * - * Writes are atomic: content goes to a temp file opened exclusively (`wx`, - * `0o600`, so a pre-existing path can never be clobbered and write-in-progress - * bytes stay owner-only) inside a randomly-named private staging directory - * (`0o700`) next to the target, then `rename`d over the target. Edits are - * read-modify-write over the same atomic primitive. - * + * Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept separate from the + * service class (mirroring `dsh-bash-local`'s `run.ts`) so the raw stat/read/write/edit + * mechanics can be unit-tested without a Context. * @module @deepseek-ai/dsh-fs-local/fsio */ @@ -121,14 +108,8 @@ export interface LocalDirEntry { } /** - * Resolve a path to its absolute display path and realpath identity. Relative - * paths are based on `cwd`. When the file itself does not yet exist, the - * `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends - * the still-missing suffix, so a not-yet-created file gets the same stable key - * it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink - * and intermediate directories are created by the write. Two input paths - * reaching the same file via symlinks share one key. Falls back to the absolute - * path only when no ancestor (not even the filesystem root) can be resolved. + * Resolve a path to its absolute display path and realpath identity. + * * @param cwd - base directory a relative `path` resolves against. * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`. * @returns the absolute display path plus the realpath-derived stable target key. @@ -363,15 +344,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow } /** - * Atomically write `content` to `absolutePath`: create parent dirs, write to a - * randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private - * (`0o700`) staging directory, fsync, optionally chmod to the final mode while - * still private, then rename over the target. `mode` (when given) preserves an - * existing file's permissions across the replace. - * @param absolutePath - the final destination (typically a target key); missing parent dirs are created. + * Atomically replace a file through a private, synced staging file in the same directory. + * @param absolutePath - destination; missing parent directories are created. * @param content - the full UTF-8 text to write. - * @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`. - * @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn. + * @param mode - final mode, or `0o600` when omitted. + * @param signal - cancellation checked before the final rename. * @param internals - test seam for pinning temp names and observing the staged file. */ export async function writeFileAtomic( @@ -492,12 +469,9 @@ export async function readForEdit( } /** - * Best-effort read of a file's current text for a before/after diff basis, used - * by an overwrite. Returns the LF-normalized decoded content, or `null` when the - * file is binary or not valid UTF-8 — a write must succeed regardless of the - * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis - * (the caller treats `null` the same as an absent file: the result renders a - * whole-file diff rather than an applied hunk). + * Best-effort read of a file's current text for a before/after diff basis, used by an + * overwrite. + * * @param absolutePath - the file to read (typically a target key); it must exist. * @param signal - aborts the read (`FS_ABORTED`). * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. @@ -515,12 +489,11 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal } /** - * Apply a literal replacement to LF-normalized content. Throws - * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and - * `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns - * the edited content (still LF-normalized) and the replacement count. + * Apply a literal replacement to LF-normalized content. + * * @param content - the current file content, already LF-normalized. - * @param oldString - literal text to find; CRLF inside it is normalized to LF before matching. + * @param oldString - literal text to find; CRLF inside it is normalized to LF before + * matching. * @param newString - literal replacement text, normalized the same way. * @param replaceAll - replace every match instead of requiring exactly one. * @param displayPath - the caller-facing path used in error messages. diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 1a3ebc57f6..80a86252f6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,15 +1,7 @@ /** - * Local-filesystem implementation of the `ctx.fs` provider seam. - * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven - * text-storage primitives with the host filesystem via - * {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses - * `realpath`, so the stable `targetKey` is the real file identity (two input - * paths reaching the same file through symlinks share one key, and writes land - * on the link target — preserving the link). - * - * Future sandboxed/remote/virtual backends are sibling packages implementing - * the same interface; loading this one populates `ctx.fs`. - * + * Local-filesystem implementation of the `ctx.fs` provider seam. {@link LocalFileSystem} + * subclasses {@link FileSystem} and backs the seven text-storage primitives with the host + * filesystem via {@link module:@deepseek-ai/dsh-fs-local/fsio}. * @module @deepseek-ai/dsh-fs-local */ @@ -156,18 +148,10 @@ export class LocalFileSystem extends FileSystem { // createIfAbsent onto an existing file: a blind overwrite — require a read first. throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED') } - // expected === undefined: unconditional create-or-overwrite (the bare - // provider) — no version guard, no read-first requirement. Still atomic - // (the per-target lock is unconditional), so the write is never torn. + // No expectation means an unconditional but still atomic write. - // Capture the prior text (the before/after diff basis) BEFORE the write. - // `null` for a create (no existing file) OR an existing-but-undiffable - // file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk - // basis, so a consumer falls back to a whole-file diff (the tool still - // renders a result-time diff card, not the raw result text). - // TODO(overwrite-diff-bound): this reads the whole prior file into memory - // for a UI-only diff; bound the pre-read and fall back to no contextual - // basis above a size threshold (see the applied-hunk-diffs RFC non-goals). + // Preserve prior text for contextual diffs; null falls back to a whole-file diff. + // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. const before = existing ? await readTextForDiff(target.targetKey, signal) : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) @@ -191,10 +175,8 @@ export class LocalFileSystem extends FileSystem { ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) - // Stale guard BEFORE literal matching: an edit based on an old read reports + // Stale guard before literal matching: an edit based on an old read reports // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content. - // A missing target reports FS_STALE_VERSION on BOTH paths (guarded and - // unconditional) — one "cannot edit this target now" code. if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') // expected === undefined: unconditional edit of the current content — no diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index 4d5c7964b7..ed8083891f 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,45 +1,7 @@ /** - * The fs-policy PLUGIN: observed-state, read-before-edit, and - * "write/edit must be based on the version you read" — added on top of the - * `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method - * service. This plugin registers NO `ctx.fsPolicy` service and exposes no - * `read`/`write`/`edit`/`resolve` methods; it influences the world only by - * deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and - * recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs` - * (the executor) free of any method coupling to the policy layer — removing - * this plugin gracefully loses the policy and leaves the unconstrained bare - * provider, rather than breaking the tool at a service-injection boundary. - * - * ## Observed state IS the prior-observation record - * - * State lives here as `WeakMap>`. An entry - * exists iff the owner has read, written, OR edited that target (every success - * emits `fs/observed`), so its presence means "this owner has observed this - * target at this version". This is what lets a create-then-edit or - * edit-then-edit sequence work without an intervening re-read: the mutation - * refreshes the recorded version to its own result. The owner is derived - * structurally from `{ agent?: { session? } }` and held weakly, so a collected - * session frees its state; disposal drops everything (HMR safety). - * - * ## Freshness via provider CAS, not stat - * - * This plugin does NO filesystem I/O. "Have you observed this file?" is a - * `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read - * still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same - * atomic lock that performs the mutation — this plugin only supplies the - * observed version as the CAS basis. Stat-ing and comparing here would open a - * TOCTOU gap the provider lock has to back up anyway, so it is deliberately - * avoided. - * - * ## Single-slot, first-wins - * - * The `fs/write-intent`/`fs/edit-intent` listeners do NOT call - * `next()`: each fully decides its single slot. The slot is first-wins by - * registration order — this plugin owning it is the default-deployment - * convention, not an event-enforced invariant (a decider registered before / - * `prepend`ed would win instead). This is not a composable authorization chain; - * layered permission/audit/sandbox interception belongs on `tools/execute`. - * + * The fs-policy plugin: observed-state, read-before-edit, and "write/edit must be based on the + * version you read" — added on top of the `ctx.fs` provider seam through the `fs/*` event + * gate, not through a method service. * @module @deepseek-ai/dsh-fs-policy */ @@ -145,15 +107,10 @@ export function apply(ctx: Context): void { // holds (a throw rejects, never escapes synchronously through the waterfall). ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor))) - // fs/edit-intent: occupy the single decision slot — do NOT call next(). - // Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise - // the edit tool's `await ctx.waterfall(...)` surfaces as its isError result. + // fs/edit-intent: occupy the single decision slot — do not call next(). ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed: synchronous, side-effect-only WeakMap write. The tool emits - // this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw — - // a throw would surface as the tool's isError result for a mutation that - // already succeeded. A WeakMap.set honors that contract. + // fs/observed: synchronous, side-effect-only WeakMap write. ctx.on('fs/observed', (target, version, actor) => { gate.observe(target, version, actor) }) diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index c41cf45701..cfe22e02d7 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,13 +1,5 @@ /** - * Tests for the fs-policy PLUGIN: it registers no service, only the - * three `fs/*` listeners. We dispatch those events directly (the unbound - * waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the - * decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread - * edit, observed-state-as-prior-observation (read/write/edit all record), - * multi-owner isolation, single-slot first-wins, and disposal/HMR release. - * - * No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only - * decides intents and records versions on its own WeakMap. + * Tests for the fs-policy plugin: it registers no service, only the three `fs/*` listeners. */ import { describe, expect, it } from 'vitest' diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 1e0ab03b85..d370728cb2 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -1,59 +1,8 @@ /** - * The filesystem provider seam (`ctx.fs`): an abstract service defining the - * text-storage primitives a backend provides — resolve a path into a stable - * target, stat its metadata, read/stream its text, write it atomically with an - * explicit intent, and apply a guarded literal edit — without saying HOW. - * Implementations subclass {@link FileSystem} and register themselves as the - * `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first. - * Future implementations swap in sandboxed, remote, virtual, or project-scoped - * backends without touching the model-facing tool schemas - * (`@deepseek-ai/dsh-tool-fs`). - * - * The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the - * capability-seam RFC for why a swappable capability is three (here four) - * packages. - * - * ## This is a provider seam, not the policy layer - * - * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns - * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the - * literal-edit critical section — but NOT line windows, numbered lines, - * rendered footers, or observed-state. Read windowing lives in the model-facing - * tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit - * are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*` - * event gate. So a sandboxed/remote backend inherits no model-facing observation - * policy it has no business carrying. - * - * `editText` stays on this seam (not composed in the policy layer from a read - * plus a write) because version guard + literal match + atomic rewrite must - * stay inside one mutation critical section for correct error attribution and - * one-wins/one-stale concurrency, and a remote backend may implement it as a - * native compare-and-edit. - * - * ## The version guard is OPTIONAL — additive policy, not subtractive - * - * `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read` - * reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally - * replaces literal text in the current content. Both mutations take their - * version guard as an OPTIONAL argument — omit it for the unconstrained - * bare-provider behavior, supply it to guard against a concurrent change. The - * mutation runs inside the backend's per-target lock either way, so an - * unconditional write/edit is still atomic; "unconditional" drops the *version* - * precondition, not the atomicity. Observed-state, read-before-edit, and - * version-guarded write/edit are NOT provider behavior — they are policy a - * plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard. - * - * ## The fs policy events live here, not in the policy plugin - * - * This package owns the `fs/write-intent`, `fs/edit-intent`, and - * `fs/observed` event vocabulary (see {@link Events}). The emitter is - * `@deepseek-ai/dsh-tool-fs` and the default listener is - * `@deepseek-ai/dsh-fs-policy`; the events live in the one package both - * already depend on, so the emitter shares a vocabulary with the policy listener - * without depending on the policy plugin. The events carry only `dsh-fs` - * vocabulary plus an opaque `object` actor — no model-facing concepts (line - * windows, numbered lines) and no agent/session owner structure leak down. - * + * The filesystem provider seam (`ctx.fs`): an abstract service defining the text-storage + * primitives a backend provides — resolve a path into a stable target, stat its metadata, + * read/stream its text, write it atomically with an explicit intent, and apply a guarded + * literal edit — without saying how. * @module @deepseek-ai/dsh-fs */ @@ -92,44 +41,26 @@ declare module 'cordis' { interface Events { /** - * Single-slot decision: produce the write intent for the next - * {@link 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 {@link + * FileSystem.writeText}. + * * @param target - the resolved target about to be written. * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** - * Single-slot decision: produce the optional version guard for the next - * {@link 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 {@link Events.'fs/write-intent'}). + * Single-slot decision: produce the optional version guard for the next {@link + * FileSystem.editText}. + * * @param target - the resolved target about to be edited. * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** - * 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. + * * @param target - the target that was read/written/edited. * @param version - the version the actor now holds as its observation. * @param actor - the observing tool-execution context; undefined records nothing useful. @@ -140,34 +71,9 @@ declare module 'cordis' { } /** - * 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: - * - {@link resolve} returns a stable {@link 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). - * - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined` - * when the target is absent. - * - {@link readText}/{@link 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`. - * - {@link 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`. - * - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL: - * omit it for an unconditional create-or-overwrite (the bare-provider default), - * or supply a {@link FsWriteIntent} to guard the write. - * - {@link 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`). + * 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). */ export abstract class FileSystem extends Service { constructor(ctx: Context) { @@ -175,18 +81,10 @@ export abstract class FileSystem extends Service { } /** - * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May - * perform I/O (a remote/sandboxed backend may need a round-trip to map a path - * to a stable identity), hence async even though the local backend only - * normalizes + realpaths. + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a + * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence + * async even though the local backend only normalizes + realpaths. * - * `opts.cwd` is the base directory a RELATIVE `path` resolves against; an - * absolute `path` ignores it. Omitted ⇒ the backend's own default base (the - * local backend uses its configured `cwd`). The CALLER supplies this — the - * seam does not read a session or agent — so a tool can resolve against the - * caller's per-session workspace (`exec.agent.session.header.cwd`) without the - * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` - * defaults a bash `workdir` to the session cwd. * @param path - the path to resolve; relative paths resolve against `opts.cwd`. * @param opts - `cwd` overrides the backend's default base for relative paths. * @returns the stable target; the same file yields the same `targetKey`. @@ -243,11 +141,8 @@ export abstract class FileSystem extends Service { abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise /** - * Apply a literal edit to an existing UTF-8 text file. When `expected` is - * supplied, verifies `expected.version` as the stale guard BEFORE literal - * matching; OMITTING it edits the current content unconditionally (no version - * guard). Either way applies the replacement and writes atomically — one - * mutation critical section — and a missing target reports `FS_STALE_VERSION`. + * Apply a literal edit to an existing UTF-8 text file. + * * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f6f5b8005f..1f86f03865 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,21 +1,7 @@ /** - * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque - * target/version identities, the metadata `stat` returns, the write-intent - * and outcome shapes, the literal-edit request/outcome, and the typed error - * taxonomy. - * - * These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and - * future sandboxed/remote backends) and by the policy layer - * (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage* - * vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand - * back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey` - * and `version` are opaque branded tokens, and `displayPath` is the only field a - * consumer may show. - * - * Model-facing concepts (line windows, numbered lines, observed-state) do NOT - * live here; they belong to the consumer tool and the policy plugin - * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`). - * + * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque target/version + * identities, the metadata `stat` returns, the write-intent and outcome shapes, the + * literal-edit request/outcome, and the typed error taxonomy. * @module @deepseek-ai/dsh-fs/types */ @@ -104,17 +90,11 @@ export interface FsDirEntry { } /** - * The explicit intent of a guarded {@link FileSystem.writeText} call. - * `createIfAbsent` creates a missing target and rejects an existing one with - * `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior - * read). `replaceIfVersion` replaces only when the target exists at the observed - * version; a missing target or a version mismatch throws `FS_STALE_VERSION`. - * - * `writeText` takes this OPTIONALLY: omitting `expected` is the third, - * unconstrained state — an unconditional create-or-overwrite (the bare - * provider). The union itself carries only the two GUARDED intents; "no guard" - * is expressed by omission, so the write and edit mutations share one symmetric - * shape (`expected?`: omit = unconditional, present = guarded). + * The explicit intent of a guarded {@link FileSystem.writeText} call. `createIfAbsent` creates + * a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy + * plugin uses when the owner has no prior read). `replaceIfVersion` replaces only when the + * target exists at the observed version; a missing target or a version mismatch throws + * `FS_STALE_VERSION`. */ export type FsWriteIntent = | { kind: 'createIfAbsent' } diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index da8b7a872e..d3e7e7c1c1 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -1,14 +1,5 @@ /** - * Result-time contextual-diff computation for the `write`/`edit` tools. Turns a - * before/after pair of file texts into one {@link FileDiff} per applied hunk — - * each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with - * ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp - * renders an editor inline diff. - * - * This is display-only presentation vocabulary (a UI concern), so it lives in - * the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns - * only the raw before/after text (storage facts) and the tool computes the diff. - * + * Result-time contextual-diff computation for the `write`/`edit` tools. * @module @deepseek-ai/dsh-tool-fs/src/diff */ @@ -29,18 +20,11 @@ export const DIFF_CONTEXT = 3 export type FsDiffMeta = { diffs: FileDiff[] } /** - * Compute one {@link FileDiff} per hunk between `before` and `after`, each - * carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an - * empty array when the texts are identical (no hunks). For a scattered - * `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s - * come back — matching the editor rendering one diff block per site. + * Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the + * applied change plus {@link DIFF_CONTEXT} context lines. * - * Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`; - * `newText` is its `+` (added) and context lines. A hunk with no old lines - * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring - * the call-time card's new-file convention. The unified-diff "\ No newline at end - * of file" markers are dropped — they annotate the patch, not file content. - * @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it). + * @param path - the path stamped on every produced diff (the model-facing `file_path`; the + * bridge relativizes it). * @param before - the file text before the change (the backend's LF-normalized diff basis). * @param after - the file text after the change, on the same basis. * @returns one diff per applied hunk, in file order; empty when the texts are identical. @@ -81,14 +65,9 @@ function isFileDiff(value: unknown): value is FileDiff { } /** - * Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff} - * hunks, or `undefined` when it is absent/malformed. `presentResult` runs on - * arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so - * it validates defensively rather than trusting the payload — a bad `meta` yields - * `undefined`, and the caller decides the fallback (edit → the generic result - * rendering; write → an args-derived whole-file diff), never a thrown presenter. - * @param meta - the opaque `tool/result` meta payload (live or replayed from the session log). - * @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload. + * Narrow opaque live or replayed result metadata to non-empty file diffs. + * @param meta - result metadata. + * @returns validated hunks, or `undefined` for absent or malformed data. */ export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 220c850d69..e9a7c60b66 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,14 +1,6 @@ /** - * The model-facing `edit` tool: update an existing UTF-8 text file by replacing - * literal text, requiring a unique match by default. The tool is the executor: - * it dispatches the `fs/edit-intent` waterfall to obtain the optional - * version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The - * default thunk returns `undefined` (unconditional edit of the current content - * — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`) - * occupies the single decision slot, returning `{ version: vObserved }` or - * throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times - * either way; a missing target is reported by the provider as `FS_STALE_VERSION`. - * + * The model-facing `edit` tool: update an existing UTF-8 text file by replacing literal text, + * requiring a unique match by default. * @module @deepseek-ai/dsh-tool-fs/src/edit */ @@ -96,22 +88,16 @@ export function applyEditTool(ctx: Context): void { ) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // The result-time applied-hunk diff (before→after with context lines). An - // edit always changes content (parseEditArgs requires old_string to differ - // and editText matches at least once), so there is always at least one hunk. - // The bridge renders these as an inline diff that supersedes the call-time - // snippet; the display path is the model-facing `file_path` (the bridge - // relativizes it). + // The result-time applied-hunk diff (before→after with context lines). const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], meta: { diffs }, } }, - // Pure display: a diff card of the literal replacement (old_string → - // new_string), derived from the call args. `oldText: old_string || null` - // matches claude-agent-acp's Edit arm; new_string is a required arg here, so - // it maps straight to newText. A follow-along location points at the file. + // Pure display: a diff card of the literal replacement (old_string → new_string), derived + // from the call args. `oldText: old_string || null` matches claude-agent-acp's Edit arm; + // new_string is a required arg here, so it maps straight to newText. presentCall(args): DiffCallView { return { card: 'diff', @@ -121,9 +107,6 @@ export function applyEditTool(ctx: Context): void { } }, // Result-time display: the applied contextual-diff hunks carried on `meta`. - // On success with diffs, a `diff` result card supersedes the call-time - // snippet; on error (nothing applied) or malformed meta, fall through to the - // generic "updated successfully" rendering. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index f5d0d9ef91..263a53b108 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,24 +1,6 @@ /** - * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fs` provider seam. This single plugin registers all three tools. - * - * ## The tool is the executor; policy is an event gate - * - * The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing - * concerns only — tool names, JSON schemas, argument validation, prompt - * sections, read windowing, result formatting. It does NOT inject a policy - * service. Instead, on each write/edit it dispatches a single-slot waterfall - * (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and - * after every read/write/edit it emits `fs/observed` with a plain (unguarded) - * `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the - * decision slot and listens for `fs/observed` to add observed-state + - * read-before-edit + version-guarded write/edit; a deployment that loads these - * tools is expected to also load it. With no policy plugin the waterfalls fall - * through to their `undefined` default (the unconstrained bare provider) and - * `fs/observed` is unheard — the tool still functions. This package never - * imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local` - * implementation. - * + * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the `ctx.fs` provider + * seam. This single plugin registers all three tools. * @module @deepseek-ai/dsh-tool-fs */ diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 8cafc0d400..f23c37be9c 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -1,18 +1,7 @@ /** - * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's - * decoded text into a bounded, line-numbered window (offset/limit, byte cap, - * per-line truncation) and format it as the model-facing text block. This is - * the `read` tool's RENDERING detail — not a storage primitive, not freshness - * policy — so it lives apart from the tool's I/O and event wiring as a pure, - * independently-testable module (no cordis, no filesystem). - * - * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text - * (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text - * for newlines and builds the requested window. A capped line buffer means a - * newline-free giant line can never balloon memory even when streamed. - * {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the - * `/` envelope the model sees. - * + * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's decoded text into a + * bounded, line-numbered window (offset/limit, byte cap, per-line truncation) and format it as + * the model-facing text block. * @module @deepseek-ai/dsh-tool-fs/read-render */ @@ -114,11 +103,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string /** * Build a bounded, line-numbered window from a file's decoded text chunks. - * Accepts an `AsyncIterable` (a chunked `streamText`) or an - * `Iterable` (a whole-file `readText` wrapped as `[text]`), so one code - * path serves both. Scans for newlines with a capped line buffer (a newline-free - * giant line is truncated, never buffered past `request.maxLineLength`), - * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. + * * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning. * @param request - the resolved window; the caller has already applied its defaults and caps. * @param displayPath - the caller-facing path used in the offset-out-of-range error. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 039d8742e9..e7073d1356 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,14 +1,6 @@ /** - * The model-facing `read` tool: inspect a UTF-8 text file and return - * line-numbered content with pagination guidance. The tool is the executor — it - * stats and reads through `ctx.fs` directly, builds the line window - * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` - * so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With - * no policy plugin the emit is simply unheard. This module owns the - * model-facing schema, argument validation, and the read I/O; the rendering - * (windowing + formatting) lives in `read-render.ts` and the - * freshness/observation policy is not its concern. - * + * The model-facing `read` tool: inspect a UTF-8 text file and return line-numbered content + * with pagination guidance. * @module @deepseek-ai/dsh-tool-fs/src/read */ @@ -98,9 +90,6 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) // One stat: type check + size routing + the version recorded as observed. - // A writer racing between this stat and the read can at worst make a LATER - // guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText - // re-checks the version in its lock). const info = await ctx.fs.stat(target, exec.signal) if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') @@ -128,12 +117,9 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, - // Pure display: a generic card titled by the file with the read window - // appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along - // location whose line is the read's offset (defaulting to 1). The window is - // derived from the RAW args (offset/limit as the model passed them), NOT the - // tool's defaulted 1/configured limit, so an unbounded read shows a bare - // title (and the presenter stays a pure function of args, config-free). + // Pure display: a generic card titled by the file with the read window appended (`Read + // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the + // read's offset (defaulting to 1). presentCall(args): GenericCallView { const { offset, limit } = args const window = limit !== undefined && limit > 0 diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 16ba17e2e5..11b216acf4 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -1,18 +1,8 @@ /** - * Derive the working directory a filesystem tool resolves relative paths - * against: the calling agent's per-session workspace - * (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit` - * act on ITS workspace, not the server's launch dir — mirroring how + * Derive the working directory a filesystem tool resolves relative paths against: the calling + * agent's per-session workspace (`exec.agent.session.header.cwd`), so each ACP session's + * `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how * `dsh-tool-bash` defaults a bash `workdir` to the session cwd. - * - * The `agent` is optional-chained — a non-agent caller yields `undefined`, and - * the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies - * its own configured default (preserving the non-ACP / no-session behavior). - * `session`/`header` are non-optional on a real `Agent`, so only `agent` needs - * the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined` - * rather than reading `process.cwd()` here keeps the default in ONE place (the - * provider), per the "explicit > implicit at seams" convention. - * * @module @deepseek-ai/dsh-tool-fs/session-cwd */ diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index fd4eec45f3..10d52427bd 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,13 +1,5 @@ /** - * The model-facing `write` tool: create or fully replace a UTF-8 text file. The - * tool is the executor: it dispatches the `fs/write-intent` waterfall to - * obtain the optional version guard, calls `ctx.fs.writeText` directly, and - * emits `fs/observed`. The default thunk returns `undefined` (unconditional - * create-or-overwrite — the bare provider); a policy plugin - * (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and - * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO - * times either way. - * + * The model-facing `write` tool: create or fully replace a UTF-8 text file. * @module @deepseek-ai/dsh-tool-fs/src/write */ @@ -75,20 +67,16 @@ export function applyWriteTool(ctx: Context): void { const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version - // exists). A create has no "before" — `outcome.before` is null — so it - // carries no `meta`; `presentResult` then renders a whole-file diff from the - // args, so the completed card is still a diff (never the result text). + // Attach a contextual hunk as `meta` only for an overwrite (a before-version exists). const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], ...diffs.length > 0 ? { meta: { diffs } } : {}, } }, - // Pure display: a diff card (an editor renders write as a new-file / full- - // replace diff). `oldText: null` — a call-time presenter has no access to the - // file's prior content, so even an overwrite renders new-file style, matching - // claude-agent-acp. A follow-along location points at the written file. + // Pure display: a diff card (an editor renders write as a new-file / full- replace diff). + // `oldText: null` — a call-time presenter has no access to the file's prior content, so + // even an overwrite renders new-file style, matching claude-agent-acp. presentCall(args): DiffCallView { return { card: 'diff', @@ -97,14 +85,9 @@ export function applyWriteTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, - // Result-time display: a `diff` card so the completed `tool_call_update` - // re-installs the diff rather than the model-facing result text (an ACP - // `tool_call_update.content` REPLACES the call's content, so a text result - // would clobber the pending diff card). An OVERWRITE uses the applied - // contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so - // its whole-file new-file diff is derived from `args.content` (replay-safe, - // matching the call-time card). An error falls through to generic rendering - // so its message shows. + // Result-time display: a `diff` card so the completed `tool_call_update` re-installs the + // diff rather than the model-facing result text (an ACP `tool_call_update.content` REPLACES + // the call's content, so a text result would clobber the pending diff card). presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c0973197eb..b90b50046e 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,16 +1,7 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the model - * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` - * so nothing bypasses the tool registry. Two deployments: - * - * - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before- - * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. - * - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to - * its undefined default, so write/edit are unconditional. This proves the - * tool carries no dependency on the policy plugin. - * - * These verify the WORLD — files are read back from disk and asserted - * byte-for-byte — not the tool's self-report. + * Integration tests: the real local backend (`dsh-fs-local`) plus the model tools + * (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` so nothing bypasses + * the tool registry. Two deployments. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,9 +19,7 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string let ctx: Context let fiber: Awaited> -// A stable session object stands in for an agent session (the file-state -// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to -// `undefined` and the backend falls back to its configured cwd (= `dir`). +// A stable session object stands in for an agent session (the file-state owner). const session = { header: {} } let callCounter = 0 @@ -290,13 +279,9 @@ describe('bare provider (no dsh-fs-policy)', () => { }) }) -// -------------------------------------------------------------------------- -// Per-session cwd: a relative file_path resolves against the CALLING session's -// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd — -// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression -// this guards: before the seam fix the tool passed no cwd, so a relative write -// landed in config.cwd instead of the session dir. -// -------------------------------------------------------------------------- +// Per-session cwd: a relative file_path resolves against the calling session's workspace +// (`exec.agent.session.header.cwd`), not the backend's config.cwd — so an ACP editor's +// per-session dir wins, matching dsh-tool-bash. describe('per-session cwd', () => { let sessionDir: string beforeEach(async () => { @@ -399,9 +384,8 @@ describe('signal, concurrency, and the fs/observed contract', () => { }) it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => { - // fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing - // listener cannot roll the write back — it only turns the tool result into - // isError. The file must still carry the written bytes. + // fs/observed is a plain ctx.emit after the write succeeded; a throwing listener cannot + // roll the write back — it only turns the tool result into isError. ctx.on('fs/observed', () => { throw new Error('recording bug') }) const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' }) expect(result.isError).toBe(true) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index c17bd875ba..6dcb0e84f9 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,11 +1,5 @@ /** - * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the - * REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy - * collaborator, per the prefer-the-real-implementation rule) over a fake - * `ctx.fs` provider, so they verify schemas, argument validation, result - * formatting, FsError→isError propagation, and that each tool dispatches the - * `fs/*` waterfalls + records observed-state through the gate (read authorizes a - * later edit) — not just that it moved bytes. + * Consumer-surface tests for the filesystem tools as the EXECUTOR. */ import { describe, expect, it, vi } from 'vitest' @@ -409,9 +403,8 @@ describe('tool-owned presentation (pure presentCall)', () => { }) describe('result-time contextual diff (meta + presentResult)', () => { - // An edit records the applied contextual hunk on `tool/result` meta, and the - // tool's presentResult narrows it back into a `diff` result card the bridge - // renders. Drive execute end-to-end so the meta is the REAL computed hunk. + // An edit records the applied contextual hunk on `tool/result` meta, and the tool's + // presentResult narrows it back into a `diff` result card the bridge renders. const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n' it('edit: execute attaches the applied hunk as meta { diffs }', async () => { @@ -452,10 +445,9 @@ describe('result-time contextual diff (meta + presentResult)', () => { }) it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { - // A create has no prior content (no `meta`), yet the completed card must be a - // `diff` — an ACP tool_call_update.content REPLACES the call's content, so a - // non-diff result would clobber the pending new-file diff. The whole-file diff - // is derived from the args (oldText:null), replay-safe. + // A create has no prior content (no `meta`), yet the completed card must be a `diff` — an + // ACP tool_call_update.content REPLACES the call's content, so a non-diff result would + // clobber the pending new-file diff. const { ctx } = await setup() const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 919d0541ba..bb2926f4eb 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,37 +1,6 @@ /** - * Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing - * the same tool call with identical arguments. - * - * Not a model-facing tool — it registers no tool, never vetoes or rewrites a - * call, and adds exactly one behavior: watch each agent's stream of tool calls - * through the `tools/post-execute` waterfall, count runs of consecutive calls - * to the same tool with identical canonicalized arguments, and at configured - * run lengths fold an escalating advisory reminder onto the decision's - * `additionalContext`. The loop appends that context as a logged - * `context/message` after the step's tool results, so the reminder is - * model-visible, source-attributed, and reconstructable from the session log - * with no new session event. Decision record: - * docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md. - * - * ```yaml - * - id: repeat-tool-guard - * name: '@deepseek-ai/dsh-repeat-tool-guard' - * config: - * thresholds: [3, 5, 8] # consecutive counts that trigger a reminder - * include: [] # tool-name patterns to track; empty = all tools - * exclude: [todo_write] # tool-name patterns transparent to the chain - * ``` - * - * Chain state is keyed per {@link AgentId} — the tool registry is a - * context-level singleton whose waterfalls interleave every agent's calls, so - * a shared counter would let one agent's repetition trip another's reminder. - * State is in-memory only: a session resumed from persistence starts with a - * fresh chain (the guard is a heuristic nudge, not a logged invariant). - * - * 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` (see docs/postmortem/0001). - * + * Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing the same tool call + * with identical arguments. * @module @deepseek-ai/dsh-repeat-tool-guard */ diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts index 59de886e5e..a7bf4457f3 100644 --- a/packages/hooks/hook-protocol/src/codec.ts +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -1,20 +1,6 @@ /** - * Parse a finished hook command's process outcome (exit code + stdout + stderr) - * into the dialect-neutral {@link HookOutput} both bridges map from. - * - * The exit-code contract is shared by Claude Code and Codex: - * - exit 0 → success; if stdout is structured JSON, parse it; else the plain - * stdout is available to the bridge (some events treat it as `additionalContext`). - * - exit 2 → BLOCKING error; stderr is the block reason fed back to the model. - * We surface this as `decision: 'block'` with `reason = stderr` so a bridge - * needs no separate exit-code branch — the neutral output already says "block". - * - other → non-blocking error; recorded (exitCode + stderr) but no decision. - * - * Structured-stdout fields are a SUPERSET across dialects (CC is richest); we - * parse every field we recognize and leave it to the bridge to honor only the - * subset meaningful for its dialect/hook point (Codex, e.g., ignores - * `allow`/`ask`/`updatedInput`). - * + * Parse a finished hook command's process outcome (exit code + stdout + stderr) into the + * dialect-neutral {@link HookOutput} both bridges map from. * @module @deepseek-ai/dsh-hook-protocol/codec */ @@ -58,49 +44,26 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision'] } /** - * Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr` - * are the captured streams; `exitCode` is the process exit (`undefined` when the - * hook could not be spawned at all). Pure and total — never throws; malformed - * JSON on a 0 exit is treated as "no structured output" (the plain stdout is - * still on the bridge to use), matching both reference engines' lenient parse of - * non-JSON stdout. - * - * `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`). - * The reference schemas key the `hookSpecificOutput` block by `hookEventName`, - * so a block whose `hookEventName` names a DIFFERENT event is malformed and its - * event-scoped fields (`permissionDecision`/`permissionDecisionReason`/ - * `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a - * `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still - * surfaced (for the log/diagnostics), and the event-agnostic top-level fields - * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`) - * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the - * block as-is — a caller that doesn't key by event opts out of the check. - * - * @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all. - * @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit. + * Decode process output into the dialect-neutral hook outcome. + * @param exitCode - process exit, or `undefined` when spawn failed. + * @param stdout - output parsed as structured JSON only on exit 0. * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2. - * @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is. + * @param expectedEventName - optional event guard for hook-specific output. * @returns the dialect-neutral decoded outcome. */ export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput { const trimmedErr = stderr.trim() const trimmedOut = stdout.trim() - // Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the - // protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit - // additionalContext), so the bridge needs it even when there's no JSON. + // Plain stdout remains available even when it is not JSON. const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut } - // Exit 2 is a blocking error in both dialects: stderr is the reason. Surface - // it as a `block` decision so the bridge maps it uniformly with a structured - // `decision:'block'` — the exit code and the JSON channel converge here. + // Both dialects treat exit 2 as a block with stderr as its reason. if (exitCode === BLOCKING_EXIT_CODE) { output.decision = 'block' if (trimmedErr.length > 0) output.reason = trimmedErr } - // Structured stdout is only consulted on a clean (0) exit; on a blocking exit - // the stderr channel is authoritative. A non-zero/undefined exit other than 2 - // carries no decision (the bridge records it as a non-blocking error). + // Structured stdout is valid only for a clean exit. if (exitCode === 0) { // Only attempt JSON when stdout looks like a JSON object — matches the // reference engines, which treat other stdout as plain text, not an error. @@ -151,12 +114,7 @@ function applyStructured(output: HookOutput, parsed: Record, ex // mismatch — the record should show what the malformed block claimed. if (eventName !== undefined) output.hookEventName = eventName // The schemas key this block by event: when a caller passes the firing event - // (`expectedEventName`), the block's `hookEventName` MUST name it. A different - // name — or a MISSING one — is malformed under the keyed schema, so discard the - // event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a - // discriminator-less block silently apply PreToolUse-scoped permission fields to - // whatever event is firing). A caller that passes no expectedEventName opts out - // of the check (applies the block as-is). + // (`expectedEventName`), the block's `hookEventName` must name it. if (expectedEventName !== undefined && eventName !== expectedEventName) { return } diff --git a/packages/hooks/hook-protocol/src/detached.ts b/packages/hooks/hook-protocol/src/detached.ts index 878ba7a27a..4bd31dcc74 100644 --- a/packages/hooks/hook-protocol/src/detached.ts +++ b/packages/hooks/hook-protocol/src/detached.ts @@ -1,16 +1,5 @@ /** - * Quiescence tracking for a bridge's DETACHED hook runs. The waterfall-shaped - * hook points (`UserPromptSubmit`, `PreToolUse`, …) are awaited by their seams, - * but the emit-shaped points (`SessionStart`, `SubagentStart`, `SubagentStop`) - * run fire-and-forget: no seam awaits them, so without tracking a bridge's - * disposal could strand a live hook process and let a late continuation fire - * into a disposed context (docs/defensive-patterns.md: dispose must reach - * quiescence). A bridge creates one tracker in `apply()`, passes - * {@link DetachedRuns.signal} to each detached {@link runHook} call, wraps the - * full run chain (the hook run PLUS its `.then` continuation) in - * {@link DetachedRuns.track}, and registers {@link DetachedRuns.drain} as its - * disposer. - * + * Quiescence tracking for a bridge's DETACHED hook runs. * @module @deepseek-ai/dsh-hook-protocol/detached */ diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index df6a10672a..a281c39047 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -1,17 +1,8 @@ /** - * Append helpers for the log-only `hook/*` session events — the durable record - * that a hook ran and what it decided. Thin wrappers over `session.append` so a - * bridge does not hand-build the payloads (and so the `turn`-enclosure + - * invoked/result pairing stay consistent across both bridges). - * - * `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no - * `surfaceOp` and append with no surface intent — but, like every event, they - * must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed - * event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/ - * `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the - * exception (its injected `context/message` is the durable evidence instead), so - * a bridge does NOT write `hook/*` for session-start — see the hooks RFC. - * + * Append helpers for the log-only `hook/*` session events — the durable record that a hook ran + * and what it decided. Thin wrappers over `session.append` so a bridge does not hand-build the + * payloads (and so the `turn`-enclosure + invoked/result pairing stay consistent across both + * bridges). * @module @deepseek-ai/dsh-hook-protocol/events */ diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index df8908490d..835a877209 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -1,27 +1,8 @@ /** - * `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex - * hook wire protocol. NOT a cordis plugin: it registers nothing and injects - * nothing. It is a LIBRARY of dialect-neutral primitives the two bridge plugins - * (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the - * identical halves of the protocol: - * - * - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect). - * - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash` - * (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral - * {@link HookOutput}. - * - {@link mergeHookOutputs} — fold multiple matched hooks into one - * most-restrictive {@link MergedHookOutcome} (deny > ask > allow). - * - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*` - * session-event helpers (declaration-merged into `SessionEventMap`); - * `appendHookResult` derives the durable `decision`/`stderrSummary` from the - * {@link HookOutput} so the shared event's semantics live in one place. - * - {@link createDetachedRuns} — quiescence tracking for the fire-and-forget - * hook points: disposal aborts and drains a bridge's detached runs. - * - * Each bridge owns what genuinely DIFFERS: building the per-event stdin payload - * (CC vs Codex field sets), the dialect's env/substitution, and mapping the - * neutral outcome onto the harness's seam-specific typed Decisions. - * + * `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex hook wire + * protocol. not a cordis plugin: it registers nothing and injects nothing. It is a LIBRARY of + * dialect-neutral primitives the two bridge plugins (`dsh-hooks-claude`, `dsh-hooks-codex`) + * import to avoid re-implementing the identical halves of the protocol. * @module @deepseek-ai/dsh-hook-protocol */ diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 6863ee999f..3c68891002 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,20 +1,6 @@ /** - * The matcher primitive shared by both hook dialects: decide whether a matcher - * pattern selects a given query (a tool name, a session source, …). - * - * The two dialects differ ONLY in how a non-empty pattern is interpreted, so - * that single axis is the {@link MatcherMode} parameter: - * - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe = - * exact-match alternation, e.g. `Edit|Write`); anything else is a regex. - * - `codex`: every pattern is an unanchored regex (no literal fast path). - * - * Both treat an absent / empty / `'*'` pattern as match-all, and both treat an - * invalid regex as a non-match: a broken matcher selects nothing rather than - * throwing into the loop. This is SILENT — the boolean return cannot distinguish - * "did not match" from "failed to compile", so a typo'd pattern (e.g. `[`) - * quietly disables that matcher with no warning. Surfacing bad config would need - * a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`). - * + * The matcher primitive shared by both hook dialects: decide whether a matcher pattern selects + * a given query (a tool name, a session source, …). * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -30,14 +16,12 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ /** * Whether `matcher` selects `query` under the given dialect {@link MatcherMode}. - * Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal - * pattern exact-matches the query (splitting `|` into alternatives); every other - * `claude` pattern and ALL `codex` patterns are tested as an unanchored regex. - * An invalid regex matches nothing (never throws). + * * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. - * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid regex. + * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid + * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { if (isMatchAll(matcher)) return true diff --git a/packages/hooks/hook-protocol/src/merge.ts b/packages/hooks/hook-protocol/src/merge.ts index d219c4eb18..8a00c5ae78 100644 --- a/packages/hooks/hook-protocol/src/merge.ts +++ b/packages/hooks/hook-protocol/src/merge.ts @@ -1,23 +1,6 @@ /** * Merge the outcomes of MULTIPLE hooks that matched one hook point into a single - * most-restrictive {@link MergedHookOutcome}. Both reference engines run matched - * hooks concurrently and fold their results; the precedence rules here are the - * intersection both dialects agree on (and the strictest interpretation where - * they differ), so a bridge gets one decision to map onto its seam: - * - * - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an - * `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter - * appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the - * rule degenerates correctly for it.) - * - **halt is sticky**: the first hook with `continue:false` sets `stop` and its - * `stopReason`. - * - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's - * `join_text_chunks`), so the model sees every objection, not just the first. - * - **context accumulates**: `additionalContext` from every hook is collected in - * order (CC concatenates; Codex keeps them as separate developer messages — - * either way the bridge gets the ordered list). - * - **systemMessages accumulate** likewise. - * + * most-restrictive {@link MergedHookOutcome}. * @module @deepseek-ai/dsh-hook-protocol/merge */ @@ -76,10 +59,9 @@ function decisionForRank(maxRank: number): MergedDecision { */ export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome { let maxRank = 0 - // Reasons collected PER RANK, so the merged reason can be the one explaining - // the WINNING decision (a deny-winning outcome surfaces deny reasons; an - // ask-winning outcome surfaces ask reasons). An `allow`'s reason is never an - // objection the model needs, so rank 1 collects none. + // Reasons collected per RANK, so the merged reason can be the one explaining the WINNING + // decision (a deny-winning outcome surfaces deny reasons; an ask-winning outcome surfaces ask + // reasons). const reasonsByRank = new Map() let stop = false let stopReason: string | undefined diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index e4a2bc8a04..086a5119e1 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -1,15 +1,8 @@ /** - * Run one configured command hook through the `ctx.bash` executor seam and parse - * its outcome into a {@link HookOutput}. This is where the wire protocol's - * EXECUTION half lives: feed the hook its JSON payload on stdin, hand it the - * dialect's env vars, honor its timeout, capture stdout/stderr/exit, and decode. - * - * It runs hooks through `ctx.bash` (not a bespoke `spawn`) deliberately — the - * bash seam already provides the scrubbed-but-overridable env, process-group - * kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields - * are the trusted-plugin surface (added for exactly this) that a hook bridge — - * an in-process plugin, not model output — is allowed to use. - * + * Run one configured command hook through the `ctx.bash` executor seam and parse its outcome + * into a {@link HookOutput}. This is where the wire protocol's EXECUTION half lives: feed the + * hook its JSON payload on stdin, hand it the dialect's env vars, honor its timeout, capture + * stdout/stderr/exit, and decode. * @module @deepseek-ai/dsh-hook-protocol/runner */ @@ -61,15 +54,9 @@ export interface RunHookResult { } /** - * Run `hook` via `bash` with `options.payload` serialized to its stdin, then - * decode the result into a {@link HookOutput}. The hook's configured - * `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`. - * The command runs with the dialect's `env` merged after the executor's - * credential scrub (the trusted-plugin path). NEVER throws: an infrastructure - * failure (the executor rejecting) is surfaced as a {@link HookOutput} with - * `exitCode: undefined`, so the caller's merge logic treats it as a - * non-blocking error rather than crashing the turn. `now` is injected for - * testable durations. + * Run `hook` via `bash` with `options.payload` serialized to its stdin, then decode the result + * into a {@link HookOutput}. + * * @param bash - the executor seam the command runs through. * @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout. * @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout. diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index ef9fc7f9b9..414c898341 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -1,15 +1,7 @@ /** - * Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol, - * plus the log-only `hook/*` session events. Types only — runtime helpers live - * in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`). - * - * This package is the SHARED CORE: the truly-identical primitives both the - * `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns - * its own per-dialect stdin-payload construction and decision mapping on top of - * these primitives — the divergences (which events exist, literal-vs-regex - * matching, env/substitution, snake_case extras, allow/ask support) are the - * BRIDGE's concern, not this lib's. - * + * Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol, plus the log-only + * `hook/*` session events. Types only — runtime helpers live in the sibling modules + * (`matcher`, `codec`, `runner`, `merge`, `events`). * @module @deepseek-ai/dsh-hook-protocol/types */ @@ -31,17 +23,7 @@ declare module '@deepseek-ai/dsh-session' { matcher?: string handlerId: string } - /** - * 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`. */ 'hook/result': { turn: number point: string diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index b447c28533..17a5848b72 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -1,13 +1,6 @@ /** - * Parse a Claude Code hook config file into the shared {@link MatcherGroup} - * shape, faithfully to CC's `hooks.json` / settings `hooks` key format. - * - * A CC config maps each event name to an array of matcher groups, each holding - * an array of typed hooks. Only `type: 'command'` hooks run here; other types - * (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but- - * degraded — the same stance Codex takes). The `command` string undergoes - * `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal. - * + * Parse a Claude Code hook config file into the shared {@link MatcherGroup} shape, faithfully + * to CC's `hooks.json` / settings `hooks` key format. * @module @deepseek-ai/dsh-hooks-claude/config */ @@ -57,13 +50,13 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri } /** - * Parse a raw Claude Code config object (the value under the `hooks` key, or a - * `hooks.json` whose top level IS that map) into runnable {@link MatcherGroup}s. - * Non-command hooks and malformed entries are dropped (recorded in `skipped` / - * silently ignored) rather than throwing — a bad hook config must not crash boot. - * `vars` are substituted into every surviving `command`. - * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map. - * @param vars - substitution values applied to every surviving `command` (defaults to none). + * Parse a raw Claude Code config object (the value under the `hooks` key, or a `hooks.json` + * whose top level IS that map) into runnable {@link MatcherGroup}s. + * + * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare + * event map. + * @param vars - substitution values applied to every surviving `command` (defaults to + * none). * @returns the runnable per-event groups plus the skipped non-command hooks. */ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig { diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index d16e26e4a6..942d408ccb 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -1,24 +1,7 @@ /** - * `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code - * hook config (`hooks.json` / a settings file's `hooks` key) on the harness's - * canonical interception seams. It is the CC DIALECT half of the hooks - * subsystem: it owns CC's per-event stdin payloads, CC's env + - * `${CLAUDE_PLUGIN_ROOT}` substitution, and the mapping from a hook's neutral - * outcome onto the harness's typed Decisions. The dialect-agnostic primitives - * (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive - * merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`. - * - * A native cordis plugin could do everything this bridge does — more powerfully, - * with typed returns and no serialization boundary. The bridge exists only to - * run UNMODIFIED external CC hooks faithfully; anything bespoke should be a - * native plugin on the same seams. - * - * Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`, - * `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only - * `type: 'command'` hooks run; the matcher group config + exit-code/stdout - * protocol are byte-faithful to CC. `updatedInput` (tool-input rewrite) is - * logged + warned, not honored (deferred — see the interception-seams RFC). - * + * `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code hook config + * (`hooks.json` / a settings file's `hooks` key) on the harness's canonical interception + * seams. * @module @deepseek-ai/dsh-hooks-claude */ @@ -129,11 +112,10 @@ export function apply(ctx: Context, config: Config): void { return } - // --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run - // detached — no seam awaits them — so every run chain is tracked and disposal - // aborts still-running hook processes, then drains the continuations - // (docs/defensive-patterns.md: dispose must reach quiescence). After the parse - // gate: a bridge that registered nothing has nothing to drain. --- + // --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run detached — no + // seam awaits them — so every run chain is tracked and disposal aborts still-running hook + // processes, then drains the continuations (docs/defensive-patterns.md: dispose must reach + // quiescence). const detached = createDetachedRuns() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') @@ -154,19 +136,11 @@ export function apply(ctx: Context, config: Config): void { ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] - // Run the hook in the AGENT'S session workspace (the `session/new` cwd on the - // session header), not the executor default (the ACP server's launch dir). - // A hook that does `pwd`, reads a relative file, or writes a marker must - // operate in the user's project tree. Absent for a no-agent run (falls back - // to the executor default). + // Run the hook in the AGENT'S session workspace (the `session/new` cwd on the session + // header), not the executor default (the ACP server's launch dir). const workdir = opts.agent?.session.header.cwd - // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to - // the session workspace (the same dir the hook RUNS in). Claude Code always - // exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR` - // (shell expansion at run time) for project-relative paths — leaving it empty - // in the default ACP wiring (no `projectDir` configured) would break them even - // though the bridge already knows the workspace. Absent only for a no-agent run - // with no configured projectDir (nothing to point at). + // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session + // workspace (the same dir the hook RUNS in). const projectDir = config.projectDir ?? workdir const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined for (const group of groups) { @@ -206,13 +180,7 @@ export function apply(ctx: Context, config: Config): void { return mergeHookOutputs(outputs) } - // TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from - // a hook's `continue:false`, but no seam below honors it — there is no - // "hard-halt the whole agent" primitive on the interception seams yet (a - // Decision can block/deny/steer a single point, not stop the run). Honoring it - // needs that primitive; deferred with the loop-guard work. Until then a - // `continue:false` hook still has its per-point effect (its decision/context), - // and the halt request is recorded in the `hook/result` log but not acted on. + // TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam. /** Build a HookContext from accumulated additionalContext strings, or undefined when none. */ function contextFrom(merged: MergedHookOutcome): HookContext | undefined { @@ -221,30 +189,14 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** - * Concatenate this bridge's {@link HookContext} (`ours`, always present at the - * call sites) with a downstream listener's optional one, so folding our - * additionalContext onto a delegated decision drops neither. The merged block - * carries a single `source` — this bridge's — because a `HookContext` holds one - * `MessageSource` and the seam cannot represent mixed provenance; the rendered - * `context/message` only distinguishes by `source.kind` ('plugin'), so a - * downstream plugin's text is still correctly framed as plugin context, not a - * user prompt. - */ + /** Merge hook context while retaining this bridge's plugin-level source. */ function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { if (!theirs) return ours return { content: [...ours.content, ...theirs.content], source: ours.source } } - // --- SessionStart: emit (cannot block). Inject any additionalContext into the - // agent. The matcher subject is the source. - // TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and - // this hook runs on a detached `.then`, so the injected context is BEST-EFFORT - // — it is not guaranteed to land before the first turn reaches the model. A - // slow hook can miss the first request (the context then arrives as a later - // injection turn). Gating startup on the hook is a loop-level change deferred - // to the interception seams; today the contract is "injected as soon as the - // hook resolves", not "before the first request". --- + // SessionStart injects context when its detached hook resolves. + // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) .then((merged) => { @@ -264,10 +216,7 @@ export function apply(ctx: Context, config: Config): void { if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } - // Our hooks did not block. DELEGATE (attaching context alone is not a veto): - // a later `agent/prompt-submit` listener must still get to block or rewrite. - // Then fold our additionalContext onto its decision — a downstream block wins - // (a dropped prompt makes the context moot; `block` carries no context field). + // Our hooks did not block. const downstream = await next() const ours = contextFrom(merged) if (!ours || downstream.kind !== 'allow') return downstream @@ -309,34 +258,20 @@ export function apply(ctx: Context, config: Config): void { } }) - // --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to - // CONTINUE (block the stop) with stderr/reason as the continuation. No matcher. - // TODO(stop-loop-guard): CC breaks an infinite force-continue with - // `stop_hook_active` (set true once a Stop hook has already fired this run) plus - // a max-consecutive cap; both are deferred. Today `stop_hook_active` is always - // false, so a Stop hook that unconditionally blocks would force-continue every - // step — a hook author must self-limit until the guard lands. --- + // A blocking Stop hook forces continuation with its reason. + // TODO(stop-loop-guard): cap consecutive forced continuations. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) if (merged.decision === 'deny') { - // A blocking Stop hook forces continuation. It carries its reason as - // next-step steering; a blocking hook that emitted no reason (exit 2, empty - // stderr) still forces the turn to continue — the block is what matters, so - // fall back to a generic steering line rather than letting the turn stop. + // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } } } return next() }) - // --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is - // observe-only this cut). A SubagentStart hook's additionalContext is injected - // into the live child; SubagentStop only observes. Both look the live child up - // so the hook runs in the child's session workspace and the payload carries - // the child's session_id/cwd (see subagentPayload). The matcher subject is the - // CC-default `agent_type` (SUBAGENT_TYPE) — the harness seam carries no - // per-kind label, so a config's default/`*`/empty agent_type matcher fires and - // a specific-kind matcher does not (documented in the RFC). --- + // --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is observe-only + // this cut). ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) @@ -347,13 +282,9 @@ export function apply(ctx: Context, config: Config): void { .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })) }) ctx.on('subagent/end', (info) => { - // Look up the child (still recoverable: `subagent/end` fires from the - // service's detached `.then` BEFORE the tool caller's `await run.result` - // disposes it) so the hook runs in the child's cwd, not the server default. - // No `.then`/inject follows (SubagentStop only observes), and no `turn` is - // passed (so no `hook/*` log records), so runPoint has nothing that can - // reject — no `.catch` is needed (the tracker's settlement bookkeeping - // would absorb one anyway). + // Look up the child (still recoverable: `subagent/end` fires from the service's detached + // `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the + // child's cwd, not the server default. const child = ctx.get('agents')?.get(info.id) detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 2cbc995ce3..c5ac2bf63b 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -302,12 +302,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) expect(existsSync(startMarker)).toBe(true) expect(existsSync(stopMarker)).toBe(true) - // The markers prove the hook PROCESSES ran, not that the detached `.then` - // continuations did (`touch` lands before the process exits). Dispose drains - // them, so the no-context arm of the SubagentStart continuation — covered - // only here — executes before this file's coverage snapshot instead of - // racing it (the arm went uncovered on a loaded CI runner and failed the - // per-file 100% branch gate). + // The markers prove the hook PROCESSES ran, not that the detached `.then` continuations did + // (`touch` lands before the process exits). await hooks.dispose() }) @@ -317,10 +313,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const pidFile = join(dir, 'pid') const marker = join(dir, 'started') const slowHook = join(dir, 'slow.sh') - // Record the hook shell's PID and touch the marker FIRST so the test can - // tell "the hook is genuinely mid-run", then sleep far past the suite - // timeout. Dispose must KILL the process (the tracker's abort signal), not - // await its exit or its 10-minute default hook timeout. + // Record the hook shell's PID and touch the marker FIRST so the test can tell "the hook is + // genuinely mid-run", then sleep far past the suite timeout. writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) chmodSync(slowHook, 0o755) writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { @@ -334,11 +328,9 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() - // Quiescence, not just promptness: the drain resolves only after the run - // settled, and the run settles only after the killed process was reaped — - // so by the time dispose returns, the PID must be GONE (kill(pid, 0) - // throws ESRCH). An untracked fire-and-forget regression would leave the - // process alive (or unreaped) and fail this deterministically. + // Quiescence, not just promptness: the drain resolves only after the run settled, and the + // run settles only after the killed process was reaped — so by the time dispose returns, + // the PID must be GONE (kill(pid, 0) throws ESRCH). expect(() => process.kill(pid, 0)).toThrow() // The aborted run resolves as a non-blocking error (runHook never rejects), // so the drained continuation must NOT have logged a failure. @@ -367,11 +359,8 @@ describe('hooks-claude bridge — load resilience', () => { }) it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { - // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it - // would veto the prompt (0 model requests) and log a hook/invoked. Build the - // ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then - // dispose it — a leaked listener fails the test (a no-op `true` hook would - // pass even leaked, so it proved nothing). + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it would veto the + // prompt (0 model requests) and log a hook/invoked. const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() @@ -393,10 +382,7 @@ describe('hooks-claude bridge — load resilience', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray - // `export default apply` would collapse the module via `unwrapExports` - // (`exports.default ?? exports`), DROP `inject`, and crash at load with - // "cannot get property … without inject". Guard the shape directly. + // Loader must retain this namespace's injection metadata. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude') expect(HooksClaude.inject).toEqual(['bash']) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 7feb46df05..79a0af4708 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -183,9 +183,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', }) it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { - // Regression: a blocking Stop hook (exit 2) with no stderr yields decision - // 'deny' + reason undefined; the turn must STILL force-continue (the block is - // what matters), not silently stop. Self-limit to one block so it can't loop. + // Regression: a blocking Stop hook (exit 2) with no stderr yields decision 'deny' + reason + // undefined; the turn must STILL force-continue (the block is what matters), not silently + // stop. const d = dir() const marker = join(d, 'fired') const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) @@ -382,10 +382,8 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // Honoring `continue:false` (hard-halt the whole run) is deferred — there is - // no such primitive on the interception seams yet. So this asserts the LOG - // faithfully records the halt request (decision "stop"), AND that the run is - // NOT actually halted: the tool still runs and the turn completes normally. + // Honoring `continue:false` (hard-halt the whole run) is deferred — there is no such + // primitive on the interception seams yet. const d = dir() const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) @@ -457,9 +455,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => }) it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // A hook that only adds context must NOT short-circuit the waterfall: a - // downstream agent/prompt-submit listener (a policy plugin) must still get to - // block the prompt. The bridge delegates via next() and folds its context. + // A hook that only adds context must not short-circuit the waterfall: a downstream + // agent/prompt-submit listener (a policy plugin) must still get to block the prompt. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) @@ -589,10 +586,8 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => { describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { - // The bug: the bridge passed no workdir, so hooks ran in the executor default - // (the server launch dir), not session/new.cwd. Here the executor default and - // the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a - // marker and we assert it ran in the SESSION cwd. + // The bug: the bridge passed no workdir, so hooks ran in the executor default (the server + // launch dir), not session/new.cwd. const serverDir = dir() const sessionDir = dir() const marker = join(sessionDir, 'where') @@ -626,11 +621,8 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server }) it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // SubagentStop looks the child up (recoverable at subagent/end) and runs the - // hook in the CHILD's session cwd, not the executor default. Here the executor - // default and the child session cwd are DIFFERENT dirs; a SubagentStop hook - // writes `pwd` to a relative marker and we assert it landed in the CHILD dir — - // which only holds if the listener threaded the child agent into runPoint. + // SubagentStop looks the child up (recoverable at subagent/end) and runs the hook in the + // CHILD's session cwd, not the executor default. const serverDir = dir() const childDir = dir() const marker = join(childDir, 'stopwhere') @@ -681,11 +673,8 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () = describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { - // Regression for the documented downgrade: session-start injection is - // detached, so a prompt sent immediately need not observe it. This asserts - // the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the - // inject first — it documents the best-effort timing rather than masking it - // by pre-waiting for context/message (which the guaranteed-timing tests do). + // Regression for the documented downgrade: session-start injection is detached, so a prompt + // sent immediately need not observe it. const d = dir() const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 505f8d2bda..681a6675d9 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -1,11 +1,5 @@ /** - * Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's - * config format is a SUBSET of Claude Code's: the same event-name → matcher-group - * structure and the same `{ type: 'command', command, timeout?/timeoutSec? }` - * hook shape, but only five events and NO command-string substitution (Codex sets - * no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's - * `async: true` commands) are parsed-and-skipped with a warning. - * + * Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. * @module @deepseek-ai/dsh-hooks-codex/config */ diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e164d98a70..72076d69a9 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,17 +1,6 @@ /** - * `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex - * `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT - * half of the hooks subsystem. - * - * Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points - * (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no - * subagent/notification/compaction), regex-only matchers, snake_case stdin - * payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and - * no command substitution, and a block-only decision model (allow/ask are not - * honored — a hook can only block, never pre-approve). The dialect-agnostic - * primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the - * Codex-specific payloads + matcher mode + decision mapping. - * + * `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex `hooks.json` on the + * harness's canonical interception seams. The CODEX DIALECT half of the hooks subsystem. * @module @deepseek-ai/dsh-hooks-codex */ @@ -112,9 +101,9 @@ export function apply(ctx: Context, config: Config): void { ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] - // Run the hook in the agent's session workspace (the `session/new` cwd), not - // the executor default (the server launch dir) — a hook reading a relative - // file or `pwd` must see the user's project tree. Absent for a no-agent run. + // Run the hook in the agent's session workspace (the `session/new` cwd), not the executor + // default (the server launch dir) — a hook reading a relative file or `pwd` must see the + // user's project tree. const workdir = opts.agent?.session.header.cwd for (const group of groups) { // Codex matches with PURE regex (no literal fast path). @@ -137,16 +126,8 @@ export function apply(ctx: Context, config: Config): void { // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, }, () => performance.now()) - // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN - // (non-JSON) stdout as additionalContext. The codec keeps that raw text on - // `output.stdout` but only sets `additionalContext` from a JSON - // `hookSpecificOutput`, so fold plain stdout in here and let the shared - // merge + contextFrom path carry it. Gated exactly like the codec's own - // structured-stdout parse: only on a clean `exitCode === 0` (a non-zero - // exit is an error, not context — an `echo x; exit 2` must not inject - // `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured - // hook's raw JSON is never dumped as prose), and never clobbering an - // explicit additionalContext from a JSON block. + // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN (non-JSON) stdout as + // additionalContext. if (opts.plainStdoutAsContext === true && output.exitCode === 0 && output.additionalContext === undefined && output.stdout.length > 0 && !output.stdout.startsWith('{')) { @@ -164,11 +145,7 @@ export function apply(ctx: Context, config: Config): void { return mergeHookOutputs(outputs) } - // TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from - // a hook's `continue:false`, but no seam below honors it — there is no - // "hard-halt the whole agent" primitive on the interception seams yet. Deferred - // with the loop-guard work; until then a `continue:false` hook keeps its - // per-point effect and the halt request is recorded in `hook/result`, not acted on. + // TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam. function contextFrom(merged: MergedHookOutcome): HookContext | undefined { if (merged.additionalContext.length === 0) return undefined @@ -176,25 +153,14 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** - * Concatenate this bridge's {@link HookContext} (`ours`, always present at the - * call sites) with a downstream listener's optional one, so folding our - * additionalContext onto a delegated decision drops neither. The merged block - * carries a single `source` — this bridge's — because a `HookContext` holds one - * `MessageSource` and the seam cannot represent mixed provenance; the rendered - * `context/message` only distinguishes by `source.kind` ('plugin'), so a - * downstream plugin's text is still correctly framed as plugin context. - */ + /** Merge hook context while retaining this bridge's plugin-level source. */ function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { if (!theirs) return ours return { content: [...ours.content, ...theirs.content], source: ours.source } } - // SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext. - // TODO(session-start-gating): a synchronous emit + detached `.then`, so the - // injected context is BEST-EFFORT — not guaranteed before the first turn reaches - // the model (a slow hook can miss the first request). Gating is a deferred - // loop-level change; the contract is "injected as soon as the hook resolves". + // SessionStart injects plain stdout when its detached hook resolves. + // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e0677306b0..25b5c2f29c 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -143,10 +143,8 @@ describe('hooks-codex bridge', () => { it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { const dir = configDir() - // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it - // would veto the prompt (0 model requests) and log a hook/invoked. After a - // clean dispose the turn must proceed untouched — this fails loudly on a leak - // (a no-op `true` hook would pass even with a leaked listener). + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it would veto the + // prompt (0 model requests) and log a hook/invoked. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([textResponse('ok')]) @@ -172,10 +170,8 @@ describe('hooks-codex bridge', () => { const dir = configDir() const pidFile = join(dir, 'pid') const marker = join(dir, 'started') - // Record the hook shell's PID and touch the marker FIRST so the test can - // tell "the hook is genuinely mid-run", then sleep far past the suite - // timeout. Dispose must KILL the process (the tracker's abort signal wired - // through this bridge's runPoint), not await its exit. + // Record the hook shell's PID and touch the marker FIRST so the test can tell "the hook is + // genuinely mid-run", then sleep far past the suite timeout. const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] }) const ctx = new Context() @@ -194,11 +190,9 @@ describe('hooks-codex bridge', () => { await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() - // Quiescence, not just promptness: the drain resolves only after the run - // settled, and the run settles only after the killed process was reaped — - // so by the time dispose returns, the PID must be GONE (kill(pid, 0) - // throws ESRCH). An untracked fire-and-forget regression would leave the - // process alive (or unreaped) and fail this deterministically. + // Quiescence, not just promptness: the drain resolves only after the run settled, and the + // run settles only after the killed process was reaped — so by the time dispose returns, + // the PID must be GONE (kill(pid, 0) throws ESRCH). expect(() => process.kill(pid, 0)).toThrow() // The aborted run resolves as a non-blocking error (runHook never rejects), // so the drained continuation must NOT have logged a failure. diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 774b308fa1..9492a94680 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -70,9 +70,8 @@ describe('hooks-codex coverage — decision mapping paths', () => { }) it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // Context alone is not a veto: a downstream agent/prompt-submit listener (a - // policy plugin registered after the bridge) must still get to block. The - // bridge delegates via next() and folds its context onto the decision. + // Context alone is not a veto: a downstream agent/prompt-submit listener (a policy plugin + // registered after the bridge) must still get to block. const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('should not run')]) @@ -438,11 +437,8 @@ describe('hooks-codex coverage — decision mapping paths', () => { }) it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { - // The plain-stdout→context fold is gated on exitCode === 0, matching the - // codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so - // an `echo stale; exit 2` here is the exact case the gate guards: without it, - // the non-clean hook's stdout would wrongly inject "stale". A marker lets us - // wait for the detached hook to finish before asserting absence. + // The plain-stdout→context fold is gated on exitCode === 0, matching the codec's + // structured-stdout rule. const d = dir() const marker = join(d, 'ran') hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index e0627c5695..1238e6f4c5 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -52,14 +52,8 @@ export class DeepSeekAdapter extends LlmAdapter { async * stream(options: GenerateOptions): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) - // TODO(http): deliberately raw `fetch` for the hand-rolled SSE body. - // `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout - // uniformity AND can stream (`responseType: 'stream'` yields the same - // ReadableStream parseSse consumes), but adopting it today - // costs a hard `undici` dependency (it does `require('undici')` with no - // globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file` - // import (pulling file-type + mime-types) for a file:// path we never hit. - // Revisit when a second adapter wants shared proxy/intercept config. + // TODO(http): adopt the Cordis HTTP service when shared transport configuration + // outweighs its additional runtime dependencies. const response = await fetch(`${this.options.baseURL}/chat/completions`, { method: 'POST', headers: { @@ -79,15 +73,9 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Paranoid by design: `code` and the HTTP status are ALREADY captured - // above (and passed to LlmError below), so the only thing this `try` - // can add is a richer provider-supplied message. A malformed, empty, - // or non-JSON error body is a normal thing for gateways/proxies to - // return on a 5xx/429 — swallowing the parse failure keeps the usable - // status-line message instead of letting a JSON.parse throw mask the - // real HTTP error. Nothing else reaches this catch: response.json() - // is the sole statement, and any non-parse failure (e.g. body already - // consumed) is equally non-actionable here. + // Paranoid by design: `code` and the HTTP status are ALREADY captured above (and passed + // to LlmError below), so the only thing this `try` can add is a richer + // provider-supplied message. } throw new LlmError(message, code, response.status) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3a3d7bb4a1..afa1a3699c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,20 +1,6 @@ /** - * DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the - * configured model names on `ctx.llm`. - * - * Config is cordis-native (schemastery). Secrets flow per the repo policy: - * `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`) - * or from the environment directly; never from ad-hoc files. - * - * ```yaml - * - id: llm-deepseek - * name: '@deepseek-ai/dsh-llm-deepseek' - * config: - * apiKey: !!js process.env.DEEPSEEK_API_KEY - * baseURL: !!js process.env.DEEPSEEK_BASE_URL - * models: [deepseek-v4-flash, deepseek-v4-pro] - * ``` - * + * DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the configured model + * names on `ctx.llm`. * @module @deepseek-ai/dsh-llm-deepseek */ diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 10ef905d7e..40da29e242 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,17 +1,6 @@ /** - * Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the - * DeepSeek chat-completions request body. - * - * Block-type mapping (core types handled explicitly; merge-extensible unions - * mean plugin-added block types exist — they are skipped, never errors): - * - * - user `text` → string content (joined) - * - assistant `text` → `content`; `reasoning` → `reasoning_content`, but - * ONLY on assistant messages that carry tool calls (the official passback - * rule for thinking mode — required there, ignored elsewhere, so we save - * the tokens elsewhere); `tool-call` → `tool_calls[]` - * - `tool-result` → its own `{role: 'tool'}` message (text flattened) - * + * Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the DeepSeek + * chat-completions request body. * @module dsh-llm-deepseek/serialize */ diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index 6856289aec..0bc56e61bb 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,15 +1,5 @@ /** * Minimal SSE (text/event-stream) parser for the chat-completions stream. - * - * Yields each event's `data:` payload as a string, ending with the literal - * `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream - * that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`. - * - * Handles the wire realities: payloads split across network reads at - * arbitrary byte positions (including mid-UTF-8), CRLF line endings, - * multi-`data:` events (joined with newlines per the SSE spec), comment - * lines, and non-data fields (ignored). - * * @module dsh-llm-deepseek/sse */ diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index cd6bc8f108..27d2ac3d1a 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -1,16 +1,5 @@ /** * Translate DeepSeek wire chunks into the harness `StreamChunk` protocol. - * - * A small state machine over the SSE payload stream: - * - `delta.content` / `delta.reasoning_content` / `delta.tool_calls[i]` each - * own one harness block (index allocated on first sight). The first - * thinking-mode chunk carries `reasoning_content: ""` — that must NOT open - * a reasoning block. - * - `finish_reason` and `usage` are DEFERRED: emitted only at the `[DONE]` - * sentinel, so the wire's two usage shapes (attached to the finish chunk, - * or a trailing usage-only chunk) both work and nothing ever follows - * `finish`. Last usage wins. - * * @module dsh-llm-deepseek/translate */ diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 00107e8e1c..65fcc84cbc 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -1,13 +1,6 @@ /** - * `PiAiAdapter`: the `@earendil-works/pi-ai`-backed implementation of the - * harness LLM seam, pointed at a DeepSeek (OpenAI-compatible) endpoint. - * - * This adapter exists as a design-verification twin of - * `@deepseek-ai/dsh-llm-deepseek`: same models, same wire protocol, - * completely different internals (a unified LLM library with its own event - * vocabulary vs hand-rolled fetch/SSE). Anything the StreamChunk protocol - * cannot express for BOTH implementations is a core-vocabulary bug. - * + * `PiAiAdapter`: the `@earendil-works/pi-ai`-backed implementation of the harness LLM seam, + * pointed at a DeepSeek (OpenAI-compatible) endpoint. * @module dsh-llm-pi-ai/adapter */ @@ -44,11 +37,8 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model< api: 'openai-completions', provider: 'deepseek', baseUrl: options.baseURL, - // Always true: pi-ai only emits the DeepSeek `thinking` field for - // reasoning-capable models, deriving enabled/disabled from whether a - // reasoningEffort option is passed. DeepSeek's provider default is - // ENABLED, so 'off' must send an explicit {type: 'disabled'} — which - // requires this flag to stay on. + // Always true: pi-ai only emits the DeepSeek `thinking` field for reasoning-capable models, + // deriving enabled/disabled from whether a reasoningEffort option is passed. reasoning: true, // DeepSeek's official effort levels: high|max (xhigh maps to max). thinkingLevelMap: { minimal: null, low: null, medium: null, high: 'high', xhigh: 'max' }, @@ -153,10 +143,8 @@ export class PiAiAdapter extends LlmAdapter { // `reasoning_effort` so the provider chooses its default effort. const reasoning = this.options.reasoning ?? 'high' - // pi-ai's event stream has no iterator-return cancellation hook: if our - // consumer stops early (break / loop abort), the underlying HTTP stream - // would keep draining. Chain an internal controller onto the caller's - // signal and abort it when this generator exits for any reason. + // pi-ai's event stream has no iterator-return cancellation hook: if our consumer stops + // early (break / loop abort), the underlying HTTP stream would keep draining. const controller = new AbortController() const onCallerAbort = (): void => { controller.abort(options.signal?.reason) } if (options.signal?.aborted) controller.abort(options.signal.reason) diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 9652cb7d56..8b409fad56 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -1,20 +1,7 @@ /** * Bidirectional mapping between the harness vocabulary and pi-ai's: - * `GenerateOptions`/`Message[]` → pi-ai `Context`, and pi-ai - * `AssistantMessageEvent`s → harness `StreamChunk`s. - * - * Vocabulary differences worth knowing (they are exactly why this adapter - * exists — an independent implementation stress-tests the StreamChunk - * protocol): - * - pi-ai tool-call `arguments` are PARSED OBJECTS; the harness keeps the - * raw JSON string. We parse on the way into pi-ai, patch provider payloads - * back to the original raw string in the adapter, and re-stringify on output. - * - pi-ai reports errors as in-stream `error` events (it never throws - * mid-stream); the harness expresses those as `finish {kind:'error'}` / - * `{kind:'aborted'}` chunks. - * - pi-ai folds reasoning tokens into `usage.output`; there is no separate - * reasoning count to map. - * + * `GenerateOptions`/`Message[]` → pi-ai `Context`, and pi-ai `AssistantMessageEvent`s → + * harness `StreamChunk`s. * @module dsh-llm-pi-ai/convert */ @@ -78,11 +65,7 @@ export function toPiContext(options: GenerateOptions): PiContext { content.push({ type: 'text', text: block.text }) break case 'reasoning': - // thinkingSignature names the wire field pi-ai replays the CoT - // under. Without it pi-ai falls back to reasoning_content: "" - // (its requiresReasoningContentOnAssistantMessages shim), which - // violates DeepSeek's thinking-mode passback rule on tool-call - // turns (guides/thinking_mode.mdx § Tool Calls). + // thinkingSignature names the wire field pi-ai replays the CoT under. content.push({ type: 'thinking', thinking: block.text, thinkingSignature: 'reasoning_content' }) break case 'tool-call': diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 1b8ba6e60c..680512ffdc 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -73,10 +73,8 @@ export class BlockAssembler { } case 'block-end': { const partial = this.ensure(chunk.index, chunk.block.type) - // First close wins: a second block-end for an already-closed index is - // a straggler (same rule as post-close deltas). Ignoring it keeps the - // streamed prefix and the final blocks() in agreement — otherwise a - // re-close could rewrite a block already flushed downstream. + // First close wins: a second block-end for an already-closed index is a straggler (same + // rule as post-close deltas). if (partial.block) return partial.block = chunk.block return chunk.block diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index 0250062eec..f305646683 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -1,14 +1,5 @@ /** * App-attribution vocabulary for provider requests. - * - * Every product LLM adapter must identify the application on every provider - * HTTP request (see the adapter contract on {@link ../index.ts LlmAdapter}): - * a static, non-secret product identity, sent as the standard `User-Agent`. - * Adapters obtain the headers from {@link attributionHeaders} instead of - * hand-copying constants, so the identity cannot drift between - * implementations. The policy and its rationale are pinned in - * docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md. - * * @module @deepseek-ai/dsh-llm/attribution */ diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 53cb157214..cdfff245a8 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,14 +1,5 @@ /** - * The call configuration of a conversation and its comparison/freeze - * utilities. `LlmCallConfig` is the non-content third of the request header - * (see `EpochHeader` in dsh-session): everything about a request besides its - * message content that can undermine provider KV-cache reuse — `model` - * selects the cache namespace outright, and the sampling scalars are treated - * the same way out of caution. It is per-conversation state recorded in the - * session log (the reconstructability RFC), never a silently-drifting - * per-call knob: the `agent/request` waterfall proposes a replacement, and - * the loop logs a real change as a `request/header-delta` event. - * + * The call configuration of a conversation and its comparison/freeze utilities. * @module dsh-llm/call-config */ @@ -39,16 +30,9 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { } /** - * Deep-freeze a value in place so any later mutation throws (ESM code runs in - * strict mode), and return it. The loop freezes every request it builds - * before dispatch — `llm/stream` listeners and adapters read the request, - * never rewrite it, so the wire bytes cannot silently desync from what the - * session log reconstructs. Guards against cycles with a WeakSet: loop-built - * requests hold `structuredClone`d JSON-validated session data, but the - * helper accepts arbitrarily constructed values. One exemption: an - * `AbortSignal` is never entered or frozen — it is the request's live - * cancellation channel, and freezing one breaks `AbortController.abort()` - * outright (Node stores the aborted flag as an own property of the signal). + * Deep-freeze a value in place so any later mutation throws (ESM code runs in strict mode), + * and return it. + * * @param value - the value to freeze in place. * @returns the same value, frozen. */ diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 2627455789..9d523577b1 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -1,13 +1,6 @@ /** - * The harness error taxonomy: one base class so failures carry a stable, - * machine-routable `code` and chain their `cause`, instead of flattening to a - * bare message string. Per-package errors extend {@link HarnessError}; the - * tool layer surfaces `{ name, code }` on results and the session `tool/result` - * event so retry/sandbox plugins and replay can distinguish failure classes. - * - * Lives in dsh-llm (the leaf package every other imports) so a single base is - * shared without a new dependency edge. See the error-taxonomy RFC. - * + * The harness error taxonomy: one base class so failures carry a stable, machine-routable + * `code` and chain their `cause`, instead of flattening to a bare message string. * @module @deepseek-ai/dsh-llm/error */ diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 667e9bca78..f457cffa10 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -55,22 +55,6 @@ export class LlmError extends HarnessError { /** * Base class for LLM provider adapters. - * - * An adapter translates between the harness vocabulary (Message/ContentBlock/ - * StreamChunk) and one provider's wire format. Adapters register themselves - * via `ctx.llm.registerAdapter(models, adapter)`. - * - * Real implementations: `@deepseek-ai/dsh-llm-deepseek` (hand-rolled - * fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two - * deliberately different internals over the same contract; see the - * adapter contract documented on `StreamChunk` in `./types.ts`. - * - * App attribution is part of the adapter contract: every HTTP request to a - * provider carries the headers from `attributionHeaders()` (`./attribution.ts`) - * — the standard `User-Agent` baseline everywhere. An adapter proves it with - * a wire-level test (a mock server asserting the received header), or, for a - * library-backed adapter, by asserting the library's header hook delivers the - * same value to the wire. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/never.ts b/packages/llm/llm/src/never.ts index 1243611415..4666928b46 100644 --- a/packages/llm/llm/src/never.ts +++ b/packages/llm/llm/src/never.ts @@ -1,23 +1,5 @@ /** * Exhaustiveness helper for switches over core unions. - * - * # When to use which pattern - * - * **Closed unions** (every variant is known at compile time in the consuming - * code — e.g. `StreamChunk` inside the assembler, `FiberState`-like enums): - * end the switch with `default: assertNever(value)`. Adding a variant then - * fails compilation at every switch that must handle it — the error appears - * exactly where work is needed. - * - * **Merge-extensible unions** (plugins add variants via declaration merging — - * `SessionEventMap`, `ContentBlockMap`, `MessageSourceMap`, …): do NOT use - * assertNever. From the core's view the union is open; plugin-added variants - * are valid values the core has never heard of. Handle the known cases and - * fall through intentionally, with a comment saying the switch is - * deliberately non-exhaustive (see `Session.deriveMessages`). The lint rule - * `switch-exhaustiveness-check` enforces that the choice is explicit either - * way. - * * @module @deepseek-ai/dsh-llm/never */ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index e3339869a5..3822fe1115 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,22 +1,5 @@ /** * Provider-neutral message and streaming vocabulary. - * - * This is the canonical language spoken by the agent loop, session logs, and - * every plugin. Adapters translate it to provider wire formats (DeepSeek V4 - * first); nothing outside an adapter should ever see a provider-specific - * shape. - * - * Extensibility: the unions in this file are derived from interfaces - * (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`) so that plugins - * can extend them via declaration merging: - * - * ```ts - * declare module '@deepseek-ai/dsh-llm' { - * interface ContentBlockMap { - * video: { type: 'video'; url: string } - * } - * } - * ``` */ import type { Branded } from '@deepseek-ai/dsh-brand' @@ -126,25 +109,6 @@ export interface TokenUsage { /** * Raw streaming protocol emitted by adapters. - * - * A streaming response interleaves several typed blocks (text, reasoning, - * multiple tool calls); `index` ties each delta to its block, and `block-end` - * carries the fully-assembled ContentBlock so consumers don't have to - * re-assemble deltas themselves (use {@link BlockAssembler} when they do). - * - * Adapter contract — every adapter MUST obey these, and every consumer may - * rely on them: - * - Emit `usage` BEFORE `finish`, and nothing after `finish` (defer both to - * the provider's end-of-stream marker so trailing usage-only chunks can't - * violate this). - * - Tool-call `arguments` stay RAW JSON strings end-to-end; partial fragments - * stream via `argumentsDelta` (providers that hand back parsed objects - * re-stringify at `block-end`). - * - Failures may either THROW from `stream()` (transport/protocol errors) or - * end the stream with `finish {kind:'error'|'aborted'}` (provider in-band - * errors, for adapters that can't throw mid-stream); consumers must handle - * both. The agent loop translates a finish-error/aborted into a turn error — - * it never logs a normal completed assistant message for a failed step. */ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } @@ -193,17 +157,8 @@ export interface GenerateOptions { stop?: string[] signal?: AbortSignal /** - * The id of the session this request belongs to — stamped by the agent loop - * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener - * route a call by WHICH session issued it (the replay adapter keys its per-call - * cursor by session, so a parent and its in-process subagent — each with its - * own session on one context — replay from their own recorded scripts). - * - * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from - * `dsh-session`: that package imports `Message` from here, so importing its - * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a - * real session id assigns with no cast. (A future ids package could own the - * brand and dissolve this note.) + * The id of the session this request belongs to — stamped by the agent loop from + * `agent.session.id`. */ sessionId?: Branded<'SessionId'> } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index d9a4fe33f3..9cb4b69d8a 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -63,10 +63,8 @@ describe('BlockAssembler', () => { it('throws from assemble() when a partial has an unhandled blockType', () => { const assembler = new BlockAssembler() - // A partial whose blockType is not text/reasoning/tool-call cannot be - // assembled without its block-end. A plugin-added block type (here - // 'video', via the merge-extensible ContentBlockMap) opened by a - // block-start with no closing block-end exercises that throw. + // A partial whose blockType is not text/reasoning/tool-call cannot be assembled without its + // block-end. assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk) expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"') }) @@ -138,10 +136,7 @@ describe('assertNever', () => { describe('BlockAssembler regressions (property-test findings)', () => { it('first block-end wins: a duplicate block-end for a closed index is ignored', () => { // Found by fast-check (the property-testing RFC): two block-ends at the same index made the - // streamed prefix (first block) disagree with final blocks() (second - // block). The first close must win — same straggler rule as post-close - // deltas — so the prefix returned incrementally by push() and the final - // blocks() stay identical. + // streamed prefix (first block) disagree with final blocks() (second block). const chunks: StreamChunk[] = [ { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index abf7511438..4a243efaaf 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -2,7 +2,7 @@ Local implementation of the [`@deepseek-ai/dsh-sandbox`](../sandbox/) seam: wraps a caller's argv in a platform confinement runner. Selection is BY PLATFORM, resolved once and cached: each platform names its runner chain, a chain of one is selected directly (probing arbitrates between candidates — a sole candidate leaves nothing to arbitrate), and a chain of several is probed functionally in preference order. Linux: [`bwrap`](https://github.com/containers/bubblewrap) when its probe passes, else the [`landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) Landlock launcher (kernel confinement that needs no userns/mount privileges — see the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the prebuilt-binary decision and profile-parity notes); darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. A platform with no chain means `confine()` FAILS CLOSED with the seam's structured `SANDBOX_UNAVAILABLE` error (win32 today: a reserved, deliberately empty chain awaiting an AppContainer-family runner); an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap's `runnerFailureSignatures` let the consumer classify that as a sandbox failure rather than a task failure. Never a silent unconfined passthrough on any path. -Policy is per call (`SandboxPolicy`: mode + workspace root); the provider holds only the mechanism and the cached ladder verdict. Every wrap reports the selected runner's `enforcement` (`full`, or `partial` on an older Landlock ABI that governs only a subset of accesses — read from the launcher's `--probe` report line) and its `denialSignatures` — the stderr dialect that rung's kernel speaks on a denied file effect (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt), which stderr-inferring consumers match instead of a cross-runner union. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile: the ladder and probes are skipped (the wrap carries both Linux denial dialects, the mechanism being unknown) — also the deterministic fake-runner seam for keyless test tiers. Its runner-failure dialect is the OUTER shell's argv0-scoped failure shapes (`exec: : not found`, `: No such file or directory`, `: Permission denied`) — the consumer re-joins the wrap through `bash -c 'exec …'`, so a missing or unexecutable configured runner classifies as a sandbox failure (fail closed at execution), never as a failing command or a denial. `probeTimeoutMs` (default 5000) bounds each functional probe, the escape hatch for hosts slow enough that a timed-out probe would otherwise misread as `SANDBOX_UNAVAILABLE`. +Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 66f3077319..df6a73613b 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,24 +1,5 @@ /** - * `LocalSandboxProvider`: the local implementation of the - * `@deepseek-ai/dsh-sandbox` seam. Wraps a caller's argv in a platform - * confinement runner selected BY PLATFORM: each platform names its runner - * chain ({@link PLATFORM_CHAINS}), a chain of one is selected directly (no - * probe — there is nothing to arbitrate), and a chain of several is probed - * FUNCTIONALLY in preference order (build and enforce a real profile once, - * not `--version`), the verdict cached for the provider's lifetime. Linux: - * `bwrap`, else the `landlock-run` Landlock launcher (kernel confinement - * that needs no userns/mount privileges; distributed as the npm package - * family `node-addon-landlock-run` — the decision recorded in - * docs/rfc/implemented/feature/2026-07-06-sandbox.md); darwin: macOS - * `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. - * When the platform has no chain or no candidate passes, - * {@link LocalSandboxProvider.confine} FAILS CLOSED with the seam's - * structured `SANDBOX_UNAVAILABLE` error instead of passing the argv - * through unconfined; an unusable runner selected WITHOUT a probe fails - * closed at execution time instead (it refuses to run the command), which - * the wrap's `runnerFailureSignatures` let consumers classify as a sandbox - * failure rather than a task failure. - * + * `LocalSandboxProvider`: the local implementation of the `@deepseek-ai/dsh-sandbox` seam. * @module @deepseek-ai/dsh-sandbox-local */ @@ -35,20 +16,7 @@ import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPoli /** 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[] /** @@ -74,14 +42,8 @@ export interface Config { } /** - * The `bwrap` profile arguments for one policy. The whole host tree is bound - * read-only; a fresh `/dev` keeps `>/dev/null` redirects working and a fresh - * `/proc` keeps process-inspecting tools working. `workspace-write` - * additionally mounts an ephemeral writable `/tmp` and rebinds the workspace - * root read-write (bind order matters: later binds overlay earlier ones). - * Deliberately NO `--unshare-pid` (it would break the process-group kill - * semantics shell consumers rely on) and NO network unsharing (the seam's - * mode vocabulary promises file effects only). + * The `bwrap` profile arguments for one policy. + * * @param policy - the file-effect policy to express as bwrap arguments. * @returns the bwrap profile arguments (before the trailing `--` + argv). */ @@ -95,19 +57,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { } /** - * The `landlock-run` grant arguments for one policy — the bwrap - * profile's file-effect semantics expressed as a Landlock allow-list - * (Landlock cannot mount, so there are no fresh/ephemeral filesystems). The - * whole tree is readable and executable; of `/dev`, ONLY `/dev/null` is - * writable — a whole-`/dev` grant would expose real host paths beneath it - * (`/dev/shm`, a shared tmpfs) to persistent writes, which `read-only` - * promises never happen. bwrap can hand out a fresh ephemeral `/dev`; on the - * host's own `/dev` the write grant must be node-by-node, and `>/dev/null` - * is the one redirects need. `workspace-write` adds the HOST `/tmp` (shared - * and persistent, where bwrap's is ephemeral — the honest difference, - * recorded in the sandbox RFC's runner notes) plus the workspace - * root read-write. The flag spelling belongs to `node-addon-landlock-run`'s - * `grantArgs`; this function owns only the policy → grants mapping. + * The `landlock-run` grant arguments for one policy — the bwrap profile's file-effect + * semantics expressed as a Landlock allow-list (Landlock cannot mount, so there are no + * fresh/ephemeral filesystems). + * * @param policy - the file-effect policy to express as launcher grants. * @returns the launcher grant arguments (before `--` + argv). */ @@ -131,9 +84,6 @@ function canonicalPath(path: string): string { return realpathSync(path) } catch { // realpathSync failed: the path (or a prefix) is missing or unreadable. - // Grant the spelling as-is — an unresolvable root matches nothing until - // it exists, which is the conservative outcome, and inventing a fallback - // resolution here would grant a path the caller never named. return path } } @@ -144,20 +94,12 @@ function sbplString(path: string): string { } /** - * The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL) - * profile with the same file-effect semantics as the other dialects, built - * as allow-default → `(deny file-write*)` → write allow-list (later rules - * win), so exactly the mode's promised file effects are governed — network - * and process visibility stay unrestricted, which is all the seam's mode - * vocabulary claims. Of `/dev`, ONLY the `/dev/null` literal is writable - * (the same node-not-directory reasoning as the Landlock grant). - * `workspace-write` adds the workspace root, the host `/tmp`, and the - * per-user darwin temp dir (`os.tmpdir()`, launchd's `TMPDIR`, inherited by - * the confined child) — on darwin that directory IS the platform's `/tmp` - * for every mkstemp-family tool, so omitting it would deny the mode's - * promised temp area. All granted roots are canonicalized because Seatbelt - * matches resolved paths ({@link canonicalPath}); duplicates after - * resolution collapse. + * The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL) profile with the + * same file-effect semantics as the other dialects, built as allow-default → `(deny + * file-write*)` → write allow-list (later rules win), so exactly the mode's promised file + * effects are governed — network and process visibility stay unrestricted, which is all the + * seam's mode vocabulary claims. + * * @param policy - the file-effect policy to express as an SBPL profile. * @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv). */ @@ -239,13 +181,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: const PLATFORM_CHAINS: Record = { linux: ['bwrap', 'landlock'], darwin: ['seatbelt'], - // Reserved slot, deliberately empty: Windows support fills it with a - // confinement runner (AppContainer / restricted-token family, shipped from - // its own repository on the landlock-run template) plus a - // SelectedRunner['runner'] union member — the switches' assertNever guards - // then walk the implementer to every site. An empty chain fails closed at - // confine(), identical to an unlisted platform: reserving the slot never - // weakens the fail-closed end. + // Reserved slot, deliberately empty: Windows support fills it with a confinement runner + // (AppContainer / restricted-token family, shipped from its own repository on the + // landlock-run template) plus a SelectedRunner['runner'] union member — the switches' + // assertNever guards then walk the implementer to every site. win32: [], } @@ -276,16 +215,9 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * The denial dialect each runner's kernel speaks — the case-insensitive - * stderr substrings a denied file effect produces under it, carried on every - * wrap (the seam's `ConfinedArgv.denialSignatures`). Kernel facts, not - * tunables: bwrap denies through its read-only bind mounts (EROFS), Landlock - * refuses with EACCES, Seatbelt with EPERM — whose text is also what - * non-file EPERM boundaries print, the residual imprecision the consumer's - * conservative classifier documents. An operator-configured `runnerCommand` - * has an unknown kernel mechanism, so its wraps carry both Linux file-denial - * dialects; bare EPERM stays excluded there (it names non-file boundaries - * the mode vocabulary does not govern). + * The denial dialect each runner's kernel speaks — the case-insensitive stderr substrings a + * denied file effect produces under it, carried on every wrap (the seam's + * `ConfinedArgv.denialSignatures`). */ const DENIAL_SIGNATURES = { bwrap: ['read-only file system'], @@ -295,15 +227,11 @@ const DENIAL_SIGNATURES = { } as const satisfies Record /** - * How each runner's OWN failure identifies itself on stderr (the seam's - * `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error - * lines with its program name, and the shell's runner-not-found message - * carries the same `name: ` shape (`bash: bwrap: command not found`, - * `bash: …/bin/landlock-run: No such file or directory`) — so one substring - * per runner covers both "runner broke" and "runner missing". Consumers - * match these BEFORE the denial dialect: a runner's error text can contain - * denial words (an unopenable grant root reports `Permission denied`), and - * a runner failure means the command never ran at all. + * How each runner's own failure identifies itself on stderr (the seam's + * `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error lines with its + * program name, and the shell's runner-not-found message carries the same `name: ` shape + * (`bash: bwrap: command not found`, `bash: …/bin/landlock-run: No such file or directory`) — + * so one substring per runner covers both "runner broke" and "runner missing". */ const RUNNER_FAILURE_SIGNATURES = { bwrap: ['bwrap: '], @@ -356,17 +284,15 @@ export class LocalSandboxProvider extends SandboxProvider { } /** - * Wrap `argv` in the selected runner's invocation for `policy` — the - * configured `runnerCommand` when present (the operator's assertion, no - * probe), else the platform chain's runner speaking its own profile - * dialect. Every wrap carries the runner's enforcement completeness, its - * denial dialect, and its runner-failure signatures. + * Wrap `argv` in the selected runner's invocation for `policy` — the configured + * `runnerCommand` when present (the operator's assertion, no probe), else the platform + * chain's runner speaking its own profile dialect. + * * @param argv - the exact argv the caller is about to spawn. * @param policy - the file-effect policy this execution runs under. - * @returns the wrapped argv plus the selected backend's enforcement - * completeness, denial signatures, and runner-failure signatures; - * throws the fail-closed `SANDBOX_UNAVAILABLE` error when the platform - * has no usable runner. + * @returns the wrapped argv plus the selected backend's enforcement completeness, denial + * signatures, and runner-failure signatures; throws the fail-closed + * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner. */ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { if (this.runnerCommand !== undefined) { @@ -375,14 +301,9 @@ export class LocalSandboxProvider extends SandboxProvider { argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv], enforcement: 'full', denialSignatures: DENIAL_SIGNATURES.runnerCommand, - // The operator names the configured runner's OWN pre-exec refusal - // dialect; the consumer additionally re-joins the wrap through an - // outer `bash -c 'exec …'`, so we can add the missing/unexecutable - // outer-shell shapes ourselves. Scoping every automatic shape to - // argv0 keeps in-command errors out (a bare `exec:`/`Permission - // denied` prefix would claim tool output; `exec: : not found` - // cannot). The residual text-collision trade is documented by the - // seam's conservative classifier contract. + // The operator names the configured runner's own pre-exec refusal dialect; the consumer + // additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the + // missing/unexecutable outer-shell shapes ourselves. runnerFailureSignatures: [ ...this.configuredRunnerFailureSignatures, `exec: ${argv0}: not found`, @@ -428,11 +349,7 @@ export class LocalSandboxProvider extends SandboxProvider { const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? [] const [first, ...rest] = chain if (first === undefined) return 'unavailable' - // One candidate = nothing to arbitrate: select it without probing. Its - // runner fails closed at EXECUTION time if unusable (refuses to run the - // command), and the wrap's runnerFailureSignatures let the consumer - // classify that as a sandbox failure — never a silent unconfined run, - // never a plain task failure. + // One candidate = nothing to arbitrate: select it without probing. if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] } for (const runner of chain) { const enforcement = this.probeRunner(runner) diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index da6e683da1..2102fac947 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -9,22 +9,8 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * KEYLESS bwrap integration proof for the BACKEND: the REAL `bwrap` confining - * REAL processes through `confine()` + a direct spawn of the returned argv. - * Nothing is forced off: bwrap is the ladder's FIRST rung, so a passing probe - * selects it naturally — the wrap shape assertion pins that. Verifies the - * WORLD (files exist or don't) and that the kernel's denial text matches the - * dialect the wrap advertises; the through-`ctx.bash` consumer proof lives - * with `@deepseek-ai/dsh-bash-sandbox`. - * - * Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a - * host that denies unprivileged user namespaces (the probe is the same - * profile the provider enforces, so skip conditions match runtime exactly). - * - * Workspaces for the workspace-write tests live under the HOME directory on - * purpose: bwrap's `/tmp` is an EPHEMERAL mount (the documented - * bwrap-profile difference — pinned by its own test below), so only a - * workspace OUTSIDE `/tmp` proves the workspace-root rebind itself. + * Keyless bwrap integration proof for the backend: the real `bwrap` confining real processes + * through `confine()` + a direct spawn of the returned argv. */ const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index 104d3b318a..166760ef3f 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -10,22 +10,10 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * KEYLESS Landlock integration proof for the BACKEND: the REAL npm-distributed - * `landlock-run` launcher (`node-addon-landlock-run`) confining REAL processes through `confine()` + a direct - * spawn of the returned argv, with the bwrap rung forced off so the ladder - * lands on the launcher. Verifies the WORLD (files exist or don't), not the - * wrapper argv alone; the through-`ctx.bash` consumer proof lives with - * `@deepseek-ai/dsh-bash-sandbox`. - * - * Self-skips when the running kernel does not enforce Landlock (or this - * platform has no launcher package — the probe cannot pass then). The - * binary itself arrives with `pnpm install`, so absence is not a checkout - * state. - * - * Workspaces live under the HOME directory on purpose: `workspace-write` - * grants the host `/tmp` wholesale (the documented Landlock-profile - * difference), so only a workspace OUTSIDE `/tmp` proves the workspace-root - * grant itself. + * Keyless Landlock integration proof for the backend: the real npm-distributed `landlock-run` + * launcher (`node-addon-landlock-run`) confining real processes through `confine()` + a direct + * spawn of the returned argv, with the bwrap rung forced off so the ladder lands on the + * launcher. */ const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' }) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 852a50e1e9..2e2acc55aa 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -252,19 +252,14 @@ describe('the platform chains', () => { }) it('a rogue chain entry throws via the probe walk\'s exhaustiveness guard (closed union)', async () => { - // Same convention as the wrap switch below: the union is closed, so a - // runner added later fails to compile at the probe switch instead of - // silently selecting without a probe. Only a cast can reach the guard. + // Same convention as the wrap switch below: the union is closed, so a runner added later + // fails to compile at the probe switch instead of silently selecting without a probe. const { sandbox } = await setup({}, { chain: ['chroot', 'bwrap'] as unknown as readonly ['bwrap'] }) expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant') }) it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => { - // The wrap switches on the chain verdict's runner tag and ends with - // assertNever: a rogue tag (only reachable by a cast — the union is - // closed and chainVerdict writes only its own literals) must throw, so a - // runner added later fails to compile at the switch instead of silently - // wrapping with another runner's dialect. + // A rogue runner tag must hit assertNever. const { sandbox } = await setup() ;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' } expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant') diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9d0e8ade4f..8d90d1668d 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -6,32 +6,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -/** - * KEYLESS publish-path rehearsal for this package's own distribution: the - * provider must work from its PACKED tarball plus its REGISTRY launcher - * dependency, not the git checkout. `pnpm pack` produces the EXACT bytes - * `pnpm publish` would upload; this suite packs the workspace closure - * (`dsh-sandbox-local` + its `@deepseek-ai` peers), installs the tarballs - * into a throwaway consumer OUTSIDE the repo — npm resolving the - * `node-addon-landlock-run` dependency (and its os/cpu-selected platform - * package) from the public registry, the real consumer path — and drives - * the INSTALLED packages under plain `node`: no tsx, no tsconfig paths, no - * workspace resolution, so a `files`-list omission, a broken launcher - * dependency, or a mode-stripped binary fails here instead of at the first - * real install. - * - * World-proofs: the registry-installed launcher carries this host's ELF - * architecture and IS executable (a tarball that loses the mode bit would - * otherwise masquerade as a non-enforcing kernel — the fail-closed branch - * below must never absorb that), and the installed provider confines a real - * process THROUGH it (bwrap forced off) — or fails closed when the running - * kernel does not enforce Landlock, which is itself the installed - * fail-closed contract. Byte provenance of the launcher is the - * `node-addon-landlock-run` repository's own release-pipeline concern. - * - * Self-skips off Linux or when the built `lib/` is absent (run - * `pnpm run build` first — CI's landlock legs do). - */ +/** Keyless packed-tarball smoke in an external plain-Node consumer. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) @@ -84,11 +59,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- tarballs.push(lines[lines.length - 1] as string) } - // A real consumer: plain ESM project, tarballs installed by npm — the - // peer ranges (^0.0.1) resolve to the tarball versions, cordis pins to - // the peer range's rc, and `node-addon-landlock-run` (with its - // os/cpu-selected platform package, an OPTIONAL dependency of the entry - // — so no `--omit=optional` here) comes from the public registry. + // Install packed tarballs in a plain ESM consumer, including optional platform dependencies. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], { cwd: consumerDir, diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 7136e4e524..d2742bb65d 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -9,20 +9,9 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local' /** - * KEYLESS Seatbelt integration proof for the BACKEND: the REAL macOS - * `sandbox-exec` confining REAL processes through `confine()` + a direct - * spawn of the returned argv, with the Linux rungs forced off so the ladder - * lands on Seatbelt. Verifies the WORLD (files exist or don't) and that the - * kernel's denial text matches the dialect the wrap advertises; the - * through-`ctx.bash` consumer proof lives with `@deepseek-ai/dsh-bash-sandbox`. - * - * Self-skips wherever the functional probe fails — every non-macOS host, or - * a macOS whose `sandbox-exec` refuses the profile. - * - * Workspaces for the workspace-write tests live under the HOME directory on - * purpose: `workspace-write` grants `/tmp` and the per-user temp dir - * wholesale (the documented Seatbelt-profile temp areas), so only a - * workspace OUTSIDE both proves the workspace-root grant itself. + * Keyless Seatbelt integration proof for the backend: the real macOS `sandbox-exec` confining + * real processes through `confine()` + a direct spawn of the returned argv, with the Linux + * rungs forced off so the ladder lands on Seatbelt. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 55b9da4540..68f870c0a2 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -1,24 +1,7 @@ /** - * The process-sandbox seam (`ctx.sandbox`): an abstract service defining WHAT - * platform confinement does — wrap a subprocess argv so it executes under a - * file-effect policy — without saying HOW. Implementations subclass - * {@link SandboxProvider} and register as the `sandbox` service; - * `@deepseek-ai/dsh-sandbox-local` (per-platform chains: Linux `bwrap` then the - * npm-distributed `landlock-run` launcher, macOS `sandbox-exec`/Seatbelt) is - * the first. - * Consumers hand over the exact argv they are about to spawn - * (`@deepseek-ai/dsh-bash-sandbox` wraps `['bash', '-c', command]`; a - * subagent backend wraps its child-agent argv) and spawn the returned argv - * instead. - * - * The seam confines SAME-WORLD subprocesses only: a backend shares the - * host's filesystem and kernel, and the policy's `workspaceRoot` names a - * real host path. Containers, microVMs, and remote executors are NOT - * backends of this seam — they are sibling implementations of whole - * capability seams (`ctx.bash`, `ctx.fs`), deployed as environment-coherent - * groups; the boundary is recorded in - * docs/rfc/implemented/feature/2026-07-06-sandbox.md. - * + * The process-sandbox seam (`ctx.sandbox`): an abstract service defining what platform + * confinement does — wrap a subprocess argv so it executes under a file-effect policy — + * without saying how. * @module @deepseek-ai/dsh-sandbox */ @@ -27,24 +10,6 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** * File-effect policy a sandbox backend enforces on confined processes. - * - * - `read-only` — the process cannot write the filesystem anywhere; a - * write-shaped `/dev/null` sink stays available so `>/dev/null` redirects - * keep working (HOW is the backend's choice: bwrap mounts a fresh `/dev`, - * the Landlock launcher and Seatbelt grant the single `/dev/null` node). - * - `workspace-write` — writes are allowed only under the policy's - * workspace root and `/tmp`; everything else stays read-only. Which `/tmp` - * is backend-specific — an ephemeral mount under bwrap, the HOST `/tmp` - * under the Landlock launcher, the host `/private/tmp` plus the per-user - * darwin temp dir under Seatbelt: the seam promises the write boundary, - * not the mount's nature. - * - `danger-full-access` — no confinement; a consumer configured with it - * spawns its argv unwrapped and never calls the provider. - * - * The mode governs FILE effects only: network and process visibility are not - * restricted (a backend that cannot honestly enforce them must not pretend - * to). How completely the file effects themselves are enforced is likewise a - * reported fact, not an assumption — see {@link SandboxEnforcement}. */ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -52,18 +17,7 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' export type ConfinedSandboxMode = Exclude /** - * How completely the selected backend enforces a confined mode's file - * effects. - * - * - `full` — every file effect the mode promises to block is governed: the - * `bwrap` mount profile, a Landlock kernel enforcing the launcher's whole - * ruleset, or an operator-configured runner (configuring one asserts full - * enforcement along with existence). - * - `partial` — the backend is active but the kernel governs only the subset - * of accesses its ABI knows (an older Landlock ABI: path-based truncate is - * ungoverned before ABI v3), so a file effect the mode promises to block - * may still land. A caller that needs the mode's promise to be absolute - * must treat `partial` as outside that promise. + * How completely the selected backend enforces a confined mode's file effects. */ export type SandboxEnforcement = 'full' | 'partial' @@ -103,17 +57,10 @@ export interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr - * substrings produced when the sandbox binary is missing, refuses its - * profile, or fails closed before exec'ing the command (`bwrap: `, - * `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own - * error prefix and the shell's runner-not-found message). ORTHOGONAL to - * {@link denialSignatures}: a denial is the confined COMMAND being blocked - * (the sandbox working as designed); a runner failure means the command - * NEVER RAN and must surface as a sandbox failure, not a task failure — - * consumers check these signatures FIRST (a runner's own error text may - * contain denial words, e.g. an unopenable grant root reporting - * `Permission denied`). + * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr substrings + * produced when the sandbox binary is missing, refuses its profile, or fails closed before + * exec'ing the command (`bwrap: `, `landlock-run: `, `sandbox-exec: ` — each covers both the + * runner's own error prefix and the shell's runner-not-found message). */ runnerFailureSignatures: readonly string[] } @@ -155,27 +102,9 @@ declare module 'cordis' { } /** - * Abstract process-sandbox service. Subclass, implement {@link 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: - * - {@link confine} either returns an argv whose runner ENFORCES the policy - * or fails closed — at `confine` time with {@link 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 - * {@link 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 {@link ConfinedArgv.enforcement} states the backend's - * actual completeness for THIS host; `partial` is reported, never silently - * upgraded to `full`. + * Abstract process-sandbox service. Subclass, implement {@link 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). */ export abstract class SandboxProvider extends Service { constructor(ctx: Context) { diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5ec01283b4..013d757736 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -72,19 +72,10 @@ function isHeaderLine(value: unknown): value is HeaderLine { } /** - * Encode an arbitrary string as a single safe path segment, injectively over - * ALL JS (UTF-16) strings — including lone surrogates. A {@link SessionId} is - * an unvalidated branded string, so this neutralizes `../`, absolute paths, - * NUL, and separators before any filesystem use. + * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16) + * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string, + * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use. * - * Each UTF-16 code unit is either kept literal (the safe set `[A-Za-z0-9_-]`) - * or escaped as `~XXXX` (its 4-hex-digit code unit). `~` is itself escaped, so - * the mapping is injective and reversible: distinct inputs never collide. We - * iterate code UNITS (`charCodeAt`), not code points, so a lone surrogate - * escapes to a distinct `~XXXX` instead of being normalized to U+FFFD (which - * `Buffer.from(…, 'utf8')` would do, breaking injectivity). `.` is in the safe - * set for readability but the whole-segment tokens `.`/`..` are escaped so they - * can never traverse. * @param raw - the string to encode; must be non-empty (throws on `''`). * @returns the escaped single path segment, decodable back to `raw`. */ @@ -141,39 +132,19 @@ export function eventLine(event: SessionEvent): string { } /** - * Parse a JSONL log buffer into its preserved event prefix (the header is line - * 0). Returns the longest prefix of complete, seq-contiguous events plus the - * byte offset of the end of the last preserved line (`committedBytes`). + * Parse a JSONL log buffer into its preserved event prefix (the header is line 0). Returns the + * longest prefix of complete, seq-contiguous events plus the byte offset of the end of the + * last preserved line (`committedBytes`). * - * A crash can leave a durable log whose final turn never closed: real, - * fully-written events sit after the last `turn/end`. Those are PRESERVED (a - * single turn can be huge in a long-horizon task — truncating it would destroy - * real work); the backend closes the orphaned open turn with a synthetic - * `turn/end {kind:'interrupted'}` on reload (the session-persistence RFC). Only a TORN trailing - * fragment — a final line never fully flushed (no newline, unparseable, or a - * seq gap) — is excluded; it bounds the preserved region. A parse error or seq - * gap AT OR BEFORE the last committed `turn/end` is committed-data corruption - * and makes the session unloadable (throws). - * - * This relies on the session-log invariant that every event lives inside a turn - * (`Session.append` enforces it): only the final turn can be open, so the - * preserved tail is at most one unclosed turn. * @param buffer - the raw bytes of the log file (header line first). * @returns the header, the preserved event prefix, and `committedBytes` — the * byte offset the next append truncates any torn tail to. */ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { const text = buffer.toString('utf8') - // Split into complete (newline-terminated) lines, tracking the byte offset of - // each line's end so the truncation point is exact (multi-byte chars make the - // char offset differ from the byte offset). A trailing line with no newline is - // an uncommitted crash fragment and is ignored — it is below the last - // turn/end by construction (the loop only flushes whole lines). - // - // Track the byte offset with a RUNNING accumulator (`endByte`), adding each - // line's byte length as we go. Recomputing `Buffer.byteLength(text.slice(0, i))` - // per newline would rescan the whole prefix every time — O(n²) over a long - // log (one assistant/chunk line per token makes that pathological). + // Split into complete (newline-terminated) lines, tracking the byte offset of each line's end + // so the truncation point is exact (multi-byte chars make the char offset differ from the + // byte offset). const lines: { text: string; endByte: number }[] = [] let start = 0 let byteOffset = 0 @@ -201,14 +172,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE } const headerLine = parsedHeader - // Find the committed region: the prefix up to and including the LAST complete - // `turn/end` in the WHOLE log. Two passes so a crash tail after the last - // turn/end is tolerated, but corruption/gaps AT OR BEFORE the last committed - // turn/end make the log unloadable (committed data must never be silently - // dropped). - // - // Pass 1: parse every line that parses, recording (parsedOk, seq, isTurnEnd, - // endByte) per line index. Lines that fail to parse are holes. + // Find the committed region: the prefix up to and including the LAST complete `turn/end` in + // the WHOLE log. interface Parsed { ok: boolean; event?: SessionEvent; endByte: number } const parsed: Parsed[] = eventEntries.map((entry) => { try { @@ -226,18 +191,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE if (p?.ok && p.event?.type === 'turn/end') { lastTurnEnd = i; break } } - // Walk the longest PREFIX of complete, seq-contiguous, parseable event lines - // (line i is a parsed event with seq === i). This is the preservable region: - // it includes any fully-written events of an interrupted final turn AFTER the - // last turn/end — those are real, durably-written work and must NOT be - // truncated (a single turn can be huge in a long-horizon task; the orphaned - // open turn is closed with a synthetic turn/end on reload, not discarded — - // the session-persistence RFC). The walk stops at the first hole (unparseable line or seq gap): - // - if that hole is AT OR BEFORE the last committed turn/end, committed data - // was damaged → the session is unloadable (throw); - // - if it is AFTER (or there is no committed turn/end yet), it is the - // tolerated crash boundary — a torn final line never fully flushed — and - // it simply bounds the preserved tail. + // Walk the longest PREFIX of complete, seq-contiguous, parseable event lines (line i is a + // parsed event with seq === i). const preserved: SessionEvent[] = [] for (let i = 0; i < parsed.length; i++) { const p = parsed[i] diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a69c979756..82522228f6 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -1,19 +1,5 @@ /** * JSONL durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-jsonl`). - * - * One append-only `.jsonl` event log per session (a header line then one - * `SessionEvent` per line, verbatim including `assistant/chunk` so `seq` stays - * contiguous), with lazy materialization (no file until the first `append`), - * atomic first write, and load-time repair of a never-committed crash tail. - * - * The backend supplies ONLY the file-bytes storage primitives (the - * {@link PersistenceBackend} hooks below); all the write-path orchestration - * (the `session/event` → buffer → `session/flush` drain, per-session - * serialization, write cursors, fork-seed persistence, HMR live-adoption, - * crash-repair sequencing, dispose quiescence) lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public - * {@link SessionPersistence} methods delegate to the coordinator. - * * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -80,10 +66,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi constructor(ctx: Context, public config: Config) { super(ctx) - // Resolve the configured root to an ABSOLUTE path ONCE, here. A relative root - // would otherwise re-resolve against `process.cwd()` at every later - // readdir/open — so if any plugin or test changed cwd between create, append, - // and load, one session's files could split across directories. + // Resolve the configured root to an ABSOLUTE path ONCE, here. this.root = resolve(config.root) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -102,11 +85,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.load(id) } - // `list` is BOTH the public service method and the PersistenceBackend hook — - // one method, the bucket walk below. The coordinator adds no orchestration for - // listing (no per-id serialization, no cursor), so it would just call back into - // this same method; routing it through the coordinator would recurse. Defined - // once, in the "PersistenceBackend hooks" section. + // `list` is BOTH the public service method and the PersistenceBackend hook — one method, the + // bucket walk below. /** * The per-session init promises, exposed for white-box tests that await a @@ -201,10 +181,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await mkdir(dir, { recursive: true, mode: 0o700 }) await this.syncDir(this.root) const finalPath = logPath(this.root, meta.cwd, meta.id) - // Never rename over an existing committed log: materialize is the FIRST write - // of a session the backend believes is new. A file here means a different - // session shares this id on disk — reject loudly. (createCore already guards - // the create path, so this is unreachable-in-practice TOCTOU defense.) + // Never rename over an existing committed log: materialize is the FIRST write of a session + // the backend believes is new. /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ if (await this.exists(finalPath)) { throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`) @@ -229,10 +207,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await link(tmp, finalPath) linked = true } finally { - // If link FAILED, the temp is the only reference and must be removed before - // the original error propagates. If it SUCCEEDED, defer temp cleanup to - // AFTER the publish is durable (below) so a temp-rm failure can never reject - // a session whose log already published. + // If link failed, the temp is the only reference and must be removed before the original + // error propagates. /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ if (!linked) await rm(tmp, { force: true }) } @@ -240,9 +216,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // entry survives a power loss: the new link is not crash-durable until the // parent directory's metadata is synced. await this.syncDir(dir) - // Best-effort temp cleanup: the log is already published and durable, so a - // failure to remove the (now-redundant) temp hard link must NOT reject the - // append. Swallow only the rm failure; nothing else of consequence runs here. + // Best-effort temp cleanup: the log is already published and durable, so a failure to + // remove the (now-redundant) temp hard link must not reject the append. try { await rm(tmp, { force: true }) } catch { @@ -351,9 +326,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi const entries = await readdir(this.root, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`) } catch (error) { - // ENOENT = the root has not been created yet → genuinely no sessions. Any - // other error (EACCES, ENOTDIR, transient I/O) must NOT be reported as "no - // sessions" — a durable backend cannot silently pretend state is absent. + // ENOENT = the root has not been created yet → genuinely no sessions. if (isENOENT(error)) return [] throw error } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 6eed63a2f4..5c6c5cfb5c 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -48,11 +48,6 @@ runPersistenceContract('jsonl', async () => { }) // Run the shared coordinator orchestration suite against the real JSONL backend. -// One temp root is the shared storage scope (two mounted instances over the same -// root = HMR/reload). `corruptTail` appends a partial, newline-less fragment to -// the session's .jsonl past the committed region — a never-committed torn tail -// that drives the coordinator's commitRepair-with-tornMarker branch over real -// file bytes. runCoordinatorContract('jsonl', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) return { @@ -340,10 +335,9 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' - // No committed turn/end, so the gap is a tolerated crash boundary: scanLog - // PRESERVES the contiguous prefix (turn/start seq 0) — real interrupted-turn - // work, not discarded — and stops at the gap. The orphaned open turn is - // closed by loadCore's synthetic turn/end, not here. + // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the + // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and + // stops at the gap. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) }) @@ -463,9 +457,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('list reads a header line longer than the 8KB read chunk', async () => { - // readFirstLine accumulates across reads when the first line exceeds its - // buffer. Plant a valid header whose line is > 8192 bytes (a long extra - // field is tolerated by the header type guard) and confirm list() reads it. + // readFirstLine accumulates across reads when the first line exceeds its buffer. const bucket = join(root, '_no-cwd') await mkdir(bucket, { recursive: true }) const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) @@ -485,10 +477,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) await sessFiberA.dispose() - // A NEW live Session object reuses id "reuse". The init cache is keyed by - // the Session OBJECT, so this gets its OWN onCreated (not A's stale promise) - // — which detects the on-disk collision and rejects, rather than silently - // appending the new session's events onto A's log under a stale cursor. + // A NEW live Session object reuses id "reuse". const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { @@ -506,13 +495,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog()) await ctx.fiber.dispose() - // Backend 2 over the SAME root. A live no-cwd session reuses id "x". Because - // loadLive(id, undefined) is the DEFINITE no-cwd bucket (NOT an all-buckets - // scan), case-2 adoption does NOT match the "/w" log — so it would NOT - // silently graft the no-cwd events onto the "/w" log with a mismatched cwd - // (the bug a non-scope-exact loadLive caused). It falls through to the - // new-session path, where createCore's any-cwd collision probe (loadStored) - // catches the duplicate id and REJECTS — the id is taken in another bucket. + // Backend 2 over the same root. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) @@ -580,9 +563,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => { - // A durable backend must NOT collapse a storage fault to "no sessions". Point - // the root at a regular FILE: readdir then fails with ENOTDIR, which must - // propagate rather than be swallowed as an empty listing. + // A durable backend must not collapse a storage fault to "no sessions". const filePath = join(root, 'not-a-dir') await writeFile(filePath, 'x') const ctx2 = new Context() @@ -593,11 +574,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => { - // A non-ENOENT error from the per-id open() must surface, not be collapsed to - // "not found" (which would let live-adoption proceed under a false absence - // assumption). A live session's onCreated reaches loadLive(id, cwd) → - // exists(logPath). Make that cwd's bucket DIRECTORY a regular file: open()ing - // `bucket/.jsonl` under it then fails ENOTDIR. + // A non-ENOENT error from the per-id open() must surface, not be collapsed to "not found" + // (which would let live-adoption proceed under a false absence assumption). const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) @@ -716,10 +694,9 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { const session = ctx.sessions.create(SessionId('reject-bad')) - // Serializability is enforced at the source: Session.append throws on a - // BigInt-bearing event BEFORE it enters session.events, so the durable log - // can never diverge from the live log. The throw surfaces at the caller's - // append site, not asynchronously in a backend flush. + // Serializability is enforced at the source: Session.append throws on a BigInt-bearing + // event before it enters session.events, so the durable log can never diverge from the live + // log. expect(() => { session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 30387b4837..f9d719657a 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -1,19 +1,5 @@ /** * SQLite durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`). - * - * A SECOND {@link SessionPersistence} implementation, built to validate that the - * abstract seam + the shared `runPersistenceContract` suite are genuinely - * backend-agnostic: the same append-only / contiguous-seq / lazy-materialization - * / interrupted-turn-close-on-load semantics the JSONL backend expresses over - * file bytes, expressed here over `node:sqlite` rows. Each `SessionEvent` maps - * 1:1 onto a row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`. - * - * Like the JSONL backend it supplies ONLY the storage primitives (the - * {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside - * transactions); all the write-path orchestration lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public - * {@link SessionPersistence} methods delegate to the coordinator. - * * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -89,10 +75,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers constructor(ctx: Context, public config: Config) { super(ctx) - // Open the database asynchronously (the parent directory may need creating); - // every hook awaits `ready` first. Opening synchronously would force a sync - // mkdir and block plugin apply. schemastery (static Config) has already - // filled `journalMode`; the cast records that runtime fact. + // Open the database asynchronously (the parent directory may need creating); every hook + // awaits `ready` first. this.ready = this.openDb(config.path, (config as Required).journalMode) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -121,10 +105,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.load(id) } - // `list` is BOTH the public service method and the PersistenceBackend hook — - // one method (the SELECT below). The coordinator adds no orchestration for - // listing, so routing it through the coordinator would just recurse. Defined - // once, in the "PersistenceBackend hooks" section. + // `list` is BOTH the public service method and the PersistenceBackend hook — one method (the + // SELECT below). /** * The per-session init promises, exposed for white-box tests that await a diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2dacbe04a0..23bdc1c7ed 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -56,35 +56,15 @@ export interface EventRow { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' /** - * Open the database at `path` and apply the schema + pragmas. `foreign_keys` - * makes `ON DELETE CASCADE` drop a session's events with its row; the - * `journal_mode` pragma is set from the plugin's `journalMode` config (`wal` - * default — the durability model the ADR records; the row shape maps 1:1 - * onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL). - * - * The table-layout version is persisted in SQLite's `PRAGMA user_version` and - * checked on open: a fresh database (user_version 0) is stamped with the - * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the - * current one (written by a different, incompatible build — older or newer) is - * REJECTED rather than opened against a layout this build does not understand. - * There are no migrations: an earlier layout is not upgraded in place — it is - * rejected. v1 had a different `sessions` shape; v2 lacked all of - * `seed_length`/`source_event_seqs`/`surface_op`. v3 is SKIPPED: two unmerged - * branches each shipped a DISTINCT v3 (one adding only `seed_length`, the other - * adding only the surface columns), so an on-disk v3 is ambiguous — it could be - * either sibling layout, neither of which has all of this build's columns. v4 - * is the merged layout carrying every column; bumping past the collided v3 - * makes the version check reject both sibling v3 databases instead of opening - * one against columns it does not have. + * Open the database, validate its version, and apply schema and pragmas. * @param path - the SQLite database file to open (created when absent). - * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. + * @param journalMode - validated journal pragma. * @returns the open handle with pragmas applied and both tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) db.exec('PRAGMA foreign_keys = ON') - // journalMode is a closed in-code union (validated by the plugin Config), not - // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params). + // The validated union is safe to interpolate into a non-bindable PRAGMA. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } @@ -93,9 +73,7 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } if (onDisk === 0) { - // Fresh (or pre-versioning) database: stamp the current layout version. - // PRAGMA does not accept bound parameters, so interpolate the integer - // constant (SCHEMA_VERSION is a trusted in-code number, not user input). + // Stamp fresh or pre-versioning databases. db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } db.exec(` @@ -162,28 +140,10 @@ export function rowToEvent(row: EventRow): SessionEvent { } /** - * The preserved prefix of an ordered event-row list (mirrors the JSONL - * backend's `scanLog`): the longest prefix of complete, seq-contiguous, - * parseable rows, PLUS the seq from which a never-committed torn tail must be - * deleted (or `undefined` if the whole list is intact). + * The preserved prefix of an ordered event-row list (mirrors the JSONL backend's `scanLog`): + * the longest prefix of complete, seq-contiguous, parseable rows, PLUS the seq from which a + * never-committed torn tail must be deleted (or `undefined` if the whole list is intact). * - * A crash can leave a durable log whose final turn never closed: real, - * fully-written rows sit after the last `turn/end`. Those are PRESERVED — a - * single turn can be huge in a long-horizon task, so truncating it would - * destroy real work; the backend closes the orphaned open turn with a synthetic - * `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is - * a torn trailing fragment — a row whose `data` never parses, or a seq gap — - * AFTER the last committed `turn/end`; that bounds the preserved region and its - * seq is returned as `tornFrom` so `load` can physically delete it. - * - * The last `turn/end` is computed from the `type` COLUMN (never parsing tail - * `data`), so a malformed `data` in an uncommitted tail row is discarded rather - * than making the session unloadable. A parse error or seq gap AT OR BEFORE the - * last committed `turn/end` is committed-data corruption and throws. - * - * This relies on the session-log invariant that every event lives inside a turn - * (`Session.append` enforces it): only the final turn can be open, so the - * preserved tail is at most one unclosed turn. * @param rows - one session's event rows, ordered by seq ascending. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. @@ -207,12 +167,7 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break } } - // Walk the longest PREFIX of complete, seq-contiguous, parseable rows - // (row i has seq === i). This includes the fully-written rows of an - // interrupted final turn AFTER the last turn/end — real work, never - // truncated. The walk stops at the first hole: - // - at or before the last committed turn/end → committed corruption (throw); - // - after it (or no committed turn/end) → tolerated torn tail (stop). + // Walk the longest PREFIX of complete, seq-contiguous, parseable rows (row i has seq === i). const preserved: SessionEvent[] = [] for (let i = 0; i < rows.length; i++) { const p = parsed[i] diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a6eda72685..4191c40995 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -41,10 +41,6 @@ runPersistenceContract('sqlite', async () => { }) // Run the shared coordinator orchestration suite against the real SQLite backend. -// A FILE-backed db (not :memory:) is the shared storage scope so two mounted -// instances see the same rows (HMR/reload). `corruptTail` INSERTs a row past the -// committed seq whose `data` is invalid JSON — a never-committed torn tail that -// drives the coordinator's commitRepair-with-tornMarker branch over real db rows. runCoordinatorContract('sqlite', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) const path = join(dir, 'sessions.db') @@ -66,9 +62,8 @@ runCoordinatorContract('sqlite', async (): Promise => { }) describe('scanRows', () => { - // scanRows works off EventRows (data is a JSON string column); build them from - // SessionEvents so the unit tests read in terms of the event vocabulary. Surface - // fields are serialized to their nullable columns so a round trip is faithful. + // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents + // so the unit tests read in terms of the event vocabulary. const rows = (events: SessionEvent[]): EventRow[] => events.map((e) => { const se = e as SessionEvent @@ -256,11 +251,8 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { - // Two unmerged branches each shipped a DISTINCT layout under user_version 3 - // (one added only `seed_length`, the other only the surface columns). The - // merged build is v4; an on-disk v3 is ambiguous and is missing at least one - // of this build's columns, so it MUST be rejected, not opened. Stamp a v3 - // database and confirm the version check refuses it. + // Two unmerged branches each shipped a DISTINCT layout under user_version 3 (one added only + // `seed_length`, the other only the surface columns). const path = await freshDbPath() openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) const db = openDatabase(path, 'wal') @@ -277,11 +269,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5 await b1.dispose() - // Hand-insert a torn tail row (seq 6, no closing turn/end) whose `data` is - // invalid JSON. The contract: only a parse error in the COMMITTED region is - // unloadable; a torn tail must be discarded. scanRows finds the last - // turn/end on the seq+type columns (never parsing tail `data`), so the - // unparsable row after it bounds the preserved prefix and is deleted by load. + // Hand-insert a torn tail row (seq 6, no closing turn/end) whose `data` is invalid JSON. const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', '{not valid json') diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0180999842..ae60a0fae0 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -1,26 +1,6 @@ /** - * The backend-agnostic write-path orchestration shared by every first-party - * {@link SessionPersistence} backend. - * - * Every durable backend needs the same orchestration: the in-memory bookkeeping - * (the per-id state, the write-behind buffers, the per-id serialization chains, - * the per-session init promises), the `session/event` → buffer → `session/flush` - * drain, lazy materialization, crash-tail repair on load, the four - * `session/created` adoption cases (new / HMR-adopt / collision / - * ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives are - * backend-specific (file bytes for `dsh-session-persistence-jsonl`, `node:sqlite` - * rows for `dsh-session-persistence-sqlite`). {@link PersistenceCoordinator} owns - * the orchestration; a backend supplies the storage primitives as a small - * {@link PersistenceBackend} hook object. - * - * The abstract {@link SessionPersistence} service's public API is independent of - * this: a backend IS a `SessionPersistence` (its four public methods delegate to - * a coordinator it composes), so a third-party backend MAY implement the service - * directly without using the coordinator at all. - * - * See the write-coordinator RFC (docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) - * for the design rationale (composition over inheritance, the opaque torn marker). - * + * The backend-agnostic write-path orchestration shared by every first-party {@link + * SessionPersistence} backend. * @module @deepseek-ai/dsh-session-persistence/coordinator */ @@ -30,16 +10,9 @@ import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek- import { assertSerializable, seedCoversPrefix } from './index.ts' /** - * A stored session's durable prefix as read back from a backend: its - * {@link SessionHeader}, the preserved (seq-contiguous, parseable) event prefix, - * and an OPAQUE `tornMarker` that is present iff a never-committed torn tail must - * be truncated before further writes. - * - * The coordinator NEVER inspects `tornMarker`'s value — it only tests - * `!== undefined` (is there a tail to repair?) and passes the value back to - * {@link PersistenceBackend.commitRepair}. Each backend chooses its own marker - * type: the JSONL backend uses the byte offset to truncate to, the SQLite - * backend uses the seq to delete from (both happen to be `number`). + * A stored session's durable prefix as read back from a backend: its {@link SessionHeader}, + * the preserved (seq-contiguous, parseable) event prefix, and an OPAQUE `tornMarker` that is + * present iff a never-committed torn tail must be truncated before further writes. */ export interface StoredPrefix { meta: SessionHeader @@ -112,16 +85,11 @@ interface SessionState { /** The next seq the backend expects to append (the stored log length). */ cursor: number /** - * Whether the backend has physically written this session (a JSONL file / - * SQLite row exists). `create()` registers state LAZILY — cursor 0, - * materialized false, nothing on disk — so an empty session leaves no - * artifact and the FIRST `appendBatch` writes the header + its events in ONE - * transaction (the "a row exists ⇔ it has events" invariant `list` - * relies on; a separate up-front materialize could crash leaving a row with - * zero events). The flag is the only signal that distinguishes a session - * registered-but-never-written from one durably present, which the reclaim - * path needs (an abandoned id with no artifact AND no buffered events is free - * to reuse; a materialized one is a real collision). + * Whether the backend has physically written this session (a JSONL file / SQLite row + * exists). `create()` registers state LAZILY — cursor 0, materialized false, nothing on disk + * — so an empty session leaves no artifact and the FIRST `appendBatch` writes the header + + * its events in one transaction (the "a row exists ⇔ it has events" invariant `list` relies + * on; a separate up-front materialize could crash leaving a row with zero events). */ materialized: boolean /** @@ -224,10 +192,9 @@ export class PersistenceCoordinator { // Validate serializability BEFORE cloning so a bad event surfaces the typed // error rather than an opaque DataCloneError from structuredClone. assertSerializable(events) - // Deep-snapshot the batch HERE, before the op waits behind the per-session - // chain: a caller that mutates a live array (e.g. session.events) — or an - // event inside it — before the op runs would otherwise have those changes - // persisted. The clone is taken synchronously (at call time). + // Deep-snapshot the batch HERE, before the op waits behind the per-session chain: a caller + // that mutates a live array (e.g. session.events) — or an event inside it — before the op + // runs would otherwise have those changes persisted. const batch = events.map(e => structuredClone(e)) return this.serialize(id, () => this.appendCore(id, batch)) } @@ -268,11 +235,9 @@ export class PersistenceCoordinator { const { meta, events, tornMarker } = stored this.assertVersion(meta) - // Crash-recovery: if the log ended mid-turn (real, preserved events but no - // closing turn/end), close it durably DURING load so disk, the returned log, - // and the cursor all agree. The interrupted turn's real events are preserved, - // never truncated (a turn can be huge — the session-persistence RFC); only a - // never-fully-written torn tail fragment is discarded. + // Crash-recovery: if the log ended mid-turn (real, preserved events but no closing + // turn/end), close it durably DURING load so disk, the returned log, and the cursor all + // agree. const closers = interruptedTurnClosers(events) const balanced = [...events, ...closers] @@ -288,12 +253,7 @@ export class PersistenceCoordinator { return { meta, events: balanced } } - // NOTE: there is deliberately no coordinator `list()`. Listing needs none of - // the coordinator's orchestration (no per-id serialization, no cursor, no - // in-memory state) — it is a pure read of stored metadata. A backend's public - // `list()` IS the {@link PersistenceBackend.list} hook (one method); routing it - // through the coordinator would only forward to that same hook, so the - // coordinator stays out of the listing path entirely. + // NOTE: there is deliberately no coordinator `list()`. // --- per-id serialization + adoption helpers --- @@ -395,9 +355,8 @@ export class PersistenceCoordinator { // against later mutation of the live event objects. const seed = session.events.map(e => structuredClone(e)) const p = this.onCreated(session, seed) - // Attach a no-op rejection handler so a failing init does not surface as an - // unhandled rejection if no flush observes `p` before it rejects. The REAL - // error is still delivered: flush/dispose await the same `p` from the map. + // Attach a no-op rejection handler so a failing init does not surface as an unhandled + // rejection if no flush observes `p` before it rejects. p.catch(() => { /* observed by flush/dispose via the stored promise */ }) this.inits.set(session, p) return p @@ -418,15 +377,6 @@ export class PersistenceCoordinator { /** * On session/created: sync the backend's in-memory state to a live Session. - * - * Cases, by whether this backend tracks the id and whether an artifact exists: - * 1. Already tracked → no-op (or claim ownerless state if the seed matches, - * or reclaim a truly-abandoned id, else reject as a collision). - * 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX - * of the live events → ADOPT it (HMR/reload), persisting any live suffix. - * 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision). - * 4. Not tracked and NO artifact → a genuinely new session: register meta - * (lazy) and persist its seed once. */ private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise { const id = session.header.id @@ -436,16 +386,7 @@ export class PersistenceCoordinator { /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */ if (tracked.owner === session) return if (tracked.owner === undefined) { - // Ownerless state from the public create()/load() API. The FIRST live - // session claims it — but ONLY if BOTH the cwd scope and the seed match. - // The cwd guard mirrors case-2's cwd-scoped loadLive(): a same-id - // ownerless artifact at a DIFFERENT cwd is a collision, not a claim - // (claiming it would append the live cwd's events under the stored - // header's cwd, the exact cross-cwd corruption the loadLive scope - // prevents). The seed guard then ensures the live events reproduce the - // persisted prefix (else a fresh, unrelated session reusing the id would - // have its seq 0..cursor-1 events filtered as already-written and - // grafted on). + // Ownerless state from the public create()/load() API. if (tracked.meta.cwd !== session.header.cwd) { throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } @@ -519,10 +460,8 @@ export class PersistenceCoordinator { } private async flush(session: Session): Promise { - // Wait for the session's init (onCreated) so the state/cursor and any - // fork-seed persistence are in place before draining. Awaiting the same - // promise initFor stored also surfaces an init failure (e.g. a collision) - // here, where the caller of session/flush observes it. + // Wait for the session's init (onCreated) so the state/cursor and any fork-seed persistence + // are in place before draining. await this.inits.get(session) // Serialize the WHOLE drain (read cursor → append → splice) on the per-session // chain so two concurrent flushes cannot both read the same cursor and @@ -534,10 +473,7 @@ export class PersistenceCoordinator { private async drain(session: Session): Promise { const buffer = this.buffers.get(session) if (!buffer?.length) return - // Copy WITHOUT removing: the buffer is the only durable-pending copy of these - // events. Drain it only AFTER the append commits; events pushed during the - // await sit past batch.length and survive the prefix splice, so a - // retry/dispose re-drains the rest. + // Copy WITHOUT removing: the buffer is the only durable-pending copy of these events. const batch = buffer.slice() const state = this.states.get(session.header.id) // Only append events at or beyond the write cursor (a resumed session's seed diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 1588ed2526..40b5822a35 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -1,23 +1,7 @@ /** - * The durable session-persistence seam (`ctx.sessionPersistence`): an abstract - * service defining WHAT a persistence backend does — durably store, reload, - * and list sessions — without saying HOW. Implementations subclass - * {@link SessionPersistence} and register themselves as the - * `sessionPersistence` service; `@deepseek-ai/dsh-session-persistence-jsonl` - * (an append-only JSONL log per session) is the first and - * `@deepseek-ai/dsh-session-persistence-sqlite` (`node:sqlite`, one row per - * event) is a second that validates the seam is backend-agnostic by passing - * the same `runPersistenceContract` suite. Further backends swap in an object - * store or a remote service without touching the consumers (the write-path - * plugin, the agent-loop resume seam). - * - * The persisted unit IS the existing {@link SessionEvent} — there is no - * parallel "persisted message" type the log must be converted to and from - * (faithful to the event-sourced model: the log is the single source of - * truth). Metadata that is NOT replayable conversation state (format version, - * cwd, lineage, seed boundary) travels separately as {@link SessionHeader}, - * which is owned by `dsh-session` and re-exported here. - * + * The durable session-persistence seam (`ctx.sessionPersistence`): an abstract service + * defining what a persistence backend does — durably store, reload, and list sessions — + * without saying how. * @module @deepseek-ai/dsh-session-persistence */ @@ -39,12 +23,10 @@ declare module 'cordis' { } /** - * Whether a live session's seed reproduces a persisted prefix exactly. Backends - * use this collision check to distinguish a legitimate resume/HMR rebind from a - * different live session reusing an existing session id. + * Whether a live session's seed reproduces a persisted prefix exactly. Backends use this + * collision check to distinguish a legitimate resume/HMR rebind from a different live session + * reusing an existing session id. * - * The comparison includes the full event payload, not just seq/type/time, so a - * mutated seed cannot be grafted onto a durable log with the same envelope. * @param seed - the live session's creation-time event snapshot. * @param prefix - the persisted prefix the seed must reproduce. * @returns `true` when the prefix fits within the seed and every event matches by JSON text. @@ -72,32 +54,9 @@ export function assertSerializable(events: readonly SessionEvent[]): void { } /** - * 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); - * {@link load} preserves them and closes the orphaned turn with synthetic - * boundary events (see {@link load}). Only a never-fully-written torn tail - * fragment is discarded. - * - **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. - * {@link load} rejects a parse error or a `seq` gap in the COMMITTED region - * (unloadable); {@link 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 {@link 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.** {@link append} returns only once the batch is durable - * (the file backend fsyncs; a DB commits). {@link create} MAY defer the - * physical write until the first {@link append} (lazy materialization). + * 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). */ export abstract class SessionPersistence extends Service { constructor(ctx: Context) { @@ -125,26 +84,10 @@ export abstract class SessionPersistence extends Service { abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** - * Reload a session: its {@link SessionHeader} plus the event log up to the last - * durable checkpoint. Returns `meta` AND `events` so the live session is - * reconstructed with its `cwd`/lineage, not just its log. + * Reload a session: its {@link SessionHeader} plus the event log up to the last durable + * checkpoint. Returns `meta` AND `events` so the live session is reconstructed with its + * `cwd`/lineage, not just its log. * - * The loop only flushes at `turn/end`, so a crash can leave a durable log - * whose final turn never closed: real, fully-written events sit after the last - * `turn/end`. Those events are PRESERVED — a single turn can be huge in a - * long-horizon task, so truncating it would destroy real work — and `load` - * CLOSES the orphaned turn by durably appending the minimal synthetic boundary - * events: an error `tool/result` for every `tool-call` the crash left - * unanswered (so the rehydrated history is a valid provider transcript — a - * dangling assistant tool-call is otherwise rejected), then a `step/end` if a - * step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }` - * reason. The returned `events` therefore end on a balanced `turn/end` and are - * immediately usable as a session seed. Only a never-fully-written TORN tail - * fragment (a half-written final record) is discarded. Returned events are - * contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the - * COMMITTED region (at or before the last real `turn/end`) makes the session - * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for - * the crash-recovery contract. * @param id - the persisted session to reload. * @returns the header plus the event log, ending on a balanced `turn/end` — * immediately usable as a session seed. diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 9f2facb827..e7e442b475 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -43,15 +43,8 @@ export function oneTurnLog(): SessionEvent[] { } /** - * Append a whole event log to a LIVE session, event by event, forwarding the - * surface metadata each event already carries. A bare `append(e.type, e.data)` - * over a `SessionEvent[]` widens the type argument to the union, where the - * typed overload's mandatory-marker rule collapses to optional — and `append`'s - * runtime guard then rejects a surface-eligible event with no marker. This - * helper forwards the `surfaceOp`/`sourceEventSeqs` VERBATIM from the source - * event (it does not synthesize a default), so a well-formed recorded log - * round-trips through a live session intact and a fixture that forgot a marker - * still trips the guard. + * Append a whole event log to a LIVE session, event by event, forwarding the surface metadata + * each event already carries. */ export function appendLog(session: Session, events: readonly SessionEvent[]): void { for (const e of events) { @@ -222,10 +215,9 @@ export function runPersistenceContract(name: string, make: () => Promise { const { persistence, dispose } = await make() try { - // Every value `isJsonValue` rejects must be rejected by the backend, not - // just BigInt — otherwise a backend could pass this contract while still - // accepting values that corrupt the durable round-trip. Each is a - // plugin-added `extra` field on a single user/message (seq 0). + // Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt — + // otherwise a backend could pass this contract while still accepting values that + // corrupt the durable round-trip. const cyclic: Record = { type: 'text', text: 'x' } cyclic['self'] = cyclic const badValues: unknown[] = [ diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 42583c4fe3..bfb665e87b 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -1,28 +1,5 @@ /** - * Reusable ORCHESTRATION suite for any backend that composes a - * {@link PersistenceCoordinator}. Where {@link runPersistenceContract} (in - * contract.ts) pins the public read/write SEMANTICS, this suite pins the - * coordinator's WRITE-PATH ORCHESTRATION — the behavior that is identical across - * every first-party backend because it lives in the shared coordinator, not in - * the storage primitives: the `session/created` → `session/event` → - * `session/flush` → dispose drain, lazy materialization, fork-seed persistence, - * the four `onCreated` adoption cases (new / HMR-adopt / collision / - * ownerless-claim), crash-tail repair on load, and dispose-time quiescence. - * - * A backend imports {@link runCoordinatorContract} and calls it with a - * {@link CoordinatorFixture} factory that knows how to (a) mount the REAL - * backend plugin on a {@link Context} over a SHARED storage scope (so HMR/reload - * tests can dispose one instance and mount another over the same bytes/rows), - * and (b) inject a never-committed torn tail for one session - * ({@link CoordinatorFixture.corruptTail}) so the through-coordinator torn-tail - * repair branch is exercised against real storage. The suite drives everything - * through the PUBLIC {@link SessionPersistence} API + the cordis SessionStore - * write path — never the storage primitives directly — so it runs unchanged for - * every backend (memory / jsonl / sqlite). - * - * Each scenario lives here once and runs once per backend through the fixture; - * the per-backend specs keep ONLY their storage-mechanics tests. - * + * Reusable ORCHESTRATION suite for any backend that composes a {@link PersistenceCoordinator}. * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract */ @@ -124,10 +101,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }) it('round-trips the seed boundary (seedLength) through persistence', async () => { - // A forked child records how many leading events were inherited via the - // seed; the boundary must survive a reload (so a resume/replay can tell the - // inherited prefix from the child's own events). Both backends carry it on - // the header — JSONL on the header line, SQLite in the seed_length column. + // A forked child records how many leading events were inherited via the seed; the + // boundary must survive a reload (so a resume/replay can tell the inherited prefix from + // the child's own events). const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { @@ -303,10 +279,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) - // Hot-reload: dispose instance 1, mount instance 2 over the SAME storage - // while the session stays live. Instance 2 has an empty states map but the - // log is materialized and is a prefix of the live events — it must ADOPT - // (not reject). A second turn appended after reload then persists. + // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the + // session stays live. await backend1.dispose() await fix.mount(ctx) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -398,9 +372,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.fiber.dispose() } - // A FRESH backend + a NEW live session with the same id but NO explicit - // resume. onCreated treats it as new; create() rejects because a log already - // exists. The rejection surfaces via the init promise (flush awaits it). + // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated + // treats it as new; create() rejects because a log already exists. const second = await freshCtx(fix) try { const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 4e4cf67822..38a12eee90 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -16,18 +16,8 @@ type MemoryStore = Map interface MemoryConfig { store?: MemoryStore } /** - * A trivial in-memory {@link SessionPersistence} that composes a - * {@link PersistenceCoordinator} over a dependency-free `Map`-backed - * {@link PersistenceBackend}. It is BOTH the coordinator's reference vehicle - * (the simplest possible storage — a `Map` with no torn - * tails, so `tornMarker` is always undefined) and the cover for the abstract - * base's constructor + service registration. The real durable backends are - * `@deepseek-ai/dsh-session-persistence-jsonl` / `-sqlite`. - * - * The store can be supplied via config so two backend instances share one Map — - * the in-RAM analogue of two backends over the same file/db, which the - * coordinator orchestration suite's HMR/reload tests need (a fresh instance with - * an empty in-memory states map adopting an already-materialized session). + * A trivial in-memory {@link SessionPersistence} that composes a {@link + * PersistenceCoordinator} over a dependency-free `Map`-backed {@link PersistenceBackend}. */ class MemoryPersistence extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] @@ -123,11 +113,6 @@ runPersistenceContract('memory', async () => { }) // Run the shared coordinator orchestration suite against the in-memory backend. -// A per-fixture Map is the shared "storage", so two mounted instances see the -// same materialized sessions (HMR/reload). `corruptTail` is OMITTED: a Map store -// writes atomically in RAM and has no torn tails, so the suite's torn-tail test -// self-skips (and asserts the omission). The real torn-tail repair branch is -// covered by the jsonl/sqlite fixtures, which CAN inject one. runCoordinatorContract('memory', async (): Promise => { const store: MemoryStore = new Map() return { diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 20ac717e9c..ed7ef1d06b 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -174,26 +174,14 @@ export class SkillService extends Service { } /** - * Register a skill provider synchronously during the provider plugin's - * `apply()`. Throws if another provider already owns the same provider name, - * including the reserved runtime provider name. Providers that need remote - * initialization do that work inside `list()` after registration. The name - * and callback identities are snapshotted at registration, so later - * replacement of those fields cannot change the registry key, dispatch - * callbacks, or HMR cleanup identity. Bound callbacks retain the original - * provider object as their receiver, so provider-owned mutable state remains - * live. Effect-scoped and HMR-safe: disposing the caller's fiber unregisters - * the provider and invalidates cached catalogs. + * Register a skill provider synchronously during the provider plugin's `apply()`. + * * @param provider - the provider to register by `provider.name`. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ registerProvider(provider: SkillProvider): () => Promise | void { - // Snapshot the registration contract before entering the effect. The - // callback binding preserves the historical method receiver while making - // replacement of `provider.list`/`provider.get` after registration inert. - // In particular, cleanup must never re-read caller-owned `provider.name`: - // an HMR host may mutate or reuse that object before its old fiber unloads. + // Snapshot the registration contract before entering the effect. const snapshot: SkillProvider = Object.freeze({ name: provider.name, list: provider.list.bind(provider), diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 39fa0253b4..8539c1d517 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -66,10 +66,8 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } - // Register after the tool so reverse-order fiber teardown removes this - // guidance listener before its referenced tool. Exact definition identity is - // the shared truth for restrictions and scoped shadows: another tool merely - // named `skill` must not inherit this plugin's catalog or instructions. + // Register after the tool so reverse-order fiber teardown removes this guidance listener + // before its referenced tool. ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index dd0aeb5aea..9b6d4eca5f 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -1,20 +1,7 @@ /** - * The out-of-process ACP subagent backend: registers a {@link SubagentProvider} - * on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven - * over the Agent Client Protocol (ACP) as the client. The parent process is the - * ACP client; the child is any ACP agent (point the configured command at the - * `acp-agent` example to "talk to our own process"). - * - * Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share - * this cordis context — it is a separate process with its own session, model - * client, and tools. So this backend injects only `subagents` (no `agents`), - * advertises NO start-time capabilities (an out-of-process child cannot enforce - * the parent's depth/tool-filter), and ignores `request.parent`. - * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default - * export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`, - * so a stray default would drop the namespace — see docs/postmortem/0001). - * + * The out-of-process ACP subagent backend: registers a {@link SubagentProvider} on + * `ctx.subagents` that runs each child agent in a spawned SUBPROCESS, driven over the Agent + * Client Protocol (ACP) as the client. * @module @deepseek-ai/dsh-subagent-acp */ diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 410f7ad7bf..708ecba3aa 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -1,24 +1,5 @@ /** - * The out-of-process ACP subagent run driver. Spawns a child agent as a - * subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the - * CLIENT, drives one session to completion, and shapes the result into a - * {@link SubagentResult}. The mirror image of the server-side bridge in - * `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP - * *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we - * IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`). - * - * One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly - * one ACP session, and `dispose` kills the subprocess and awaits its exit. - * Persistent-process pooling is a future optimization (see the RFC). - * - * TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a - * distinct replay shape — each child is its own PROCESS with its own - * single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own - * sessions-root + fixture), unlike the in-process per-session keying in - * `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a - * scripted mock ACP server subprocess, and the with-key e2e drives the real - * `acp-agent` example. See the ACP-subagent-backend RFC. - * + * The out-of-process ACP subagent run driver. * @module @deepseek-ai/dsh-subagent-acp/run */ @@ -96,16 +77,9 @@ export interface AcpRunSpec { } /** - * Default grace for the child's EOF-driven quiesce on dispose (the - * `disposeEofGraceMs` config) — the window for it to flush persistence and tear - * down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL` - * escalation) before the parent escalates to a signal. Deliberately LARGER than - * {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself - * waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s - * SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single - * signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it - * reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is - * a standalone generous default, NOT derived from any child's internals. + * Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config) + * — the window for it to flush persistence and tear down its own nested subprocesses (which + * may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 @@ -176,13 +150,6 @@ function toError(value: unknown): Error { /** * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. * - * Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, - * and drives one session: `initialize` → `newSession` → `prompt`. The accumulated - * `agent_message_chunk` text is the result output; the prompt's terminal - * `StopReason` maps to the stop reason. `result` never REJECTS on a child-level - * failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per - * the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the - * subprocess and awaits its exit (quiescent teardown). * @param request - the start request; the driver consumes `prompt` and `signal` * (an already-aborted signal yields an inert `aborted` run with no spawn). * @param spec - the resolved spawn spec: command/args/cwd, env, permission @@ -227,12 +194,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] - // `cancelled` records that a cancel was requested (signal or cancel()), so a - // run torn down before the prompt resolves settles `aborted` rather than the - // generic error mapping. Held on a mutable object so the async closures that - // set it (the abort listener) and the IIFE that reads it don't fight TS's - // control-flow narrowing of a bare `let` (which would type the catch-time read - // as always-`false`). + // `cancelled` records that a cancel was requested (signal or cancel()), so a run torn down + // before the prompt resolves settles `aborted` rather than the generic error mapping. const flags = { cancelled: false } const makeClient = (_agent: AcpAgent): Client => ({ @@ -268,27 +231,14 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su ) let sessionId: string | undefined - // Resolves when a cancel is requested, so `result` can settle `aborted` even - // if the child never cooperates with `session/cancel` (it ignores the notify, - // or the prompt wedges). The result path races this against the ACP drive: the - // FIRST to settle wins, so `cancel()` always honors the contract (`result` - // settles `aborted`) without waiting on a non-cooperative child. `dispose` - // still kills the process and reaps it; this only unblocks `result`. The - // executor runs synchronously, so `signalCancelSettled` is assigned before the - // Promise constructor returns (the `!` asserts the definite assignment). + // Resolves when a cancel is requested, so `result` can settle `aborted` even if the child + // never cooperates with `session/cancel` (it ignores the notify, or the prompt wedges). let signalCancelSettled!: () => void const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) const requestCancel = (): void => { flags.cancelled = true signalCancelSettled() - // Best-effort: tell the child to cancel the in-flight turn. Swallows a - // rejection — the session may not exist yet, or the pipe may be gone; the - // dispose path kills the process regardless. If the session has NOT been - // created yet (cancel raced ahead of `newSession`), the `cancelled` flag - // alone carries it: the result path re-checks the flag after each await and - // settles `aborted` without running the prompt. The `.catch` swallow is - // defensive for a narrow transport race (child gone mid-send) — v8-ignored - // because dispose kills the process regardless, so it can't be hit in tests. + // Best-effort: tell the child to cancel the in-flight turn. /* v8 ignore next */ if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) } @@ -303,12 +253,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return text.length > 0 ? [{ type: 'text', text }] : [] } - // A provider is "started" only once the remote child has completed ACP - // initialization and published a session. SubagentService gates its - // `subagent/start` notification on this boundary, just as the in-process - // provider gates it on local Agent publication. Failure or cancellation - // before this point rejects readiness and therefore produces no paired - // lifecycle events for a child that never became live. + // A provider is "started" only once the remote child has completed ACP initialization and + // published a session. const started: Promise = Promise.race([ (async (): Promise => { await conn.initialize({ @@ -327,20 +273,13 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su const result: Promise = (async (): Promise => { try { - // Readiness is the initialize → newSession phase above. Awaiting the SAME - // promise immediately observes its rejection even without the service, - // and guarantees the prompt phase never starts before the provider can - // truthfully announce a live child. + // Readiness is the initialize → newSession phase above. await started - // Race two post-start outcomes, first to settle wins: - // - prompt: the normal remote turn; - // - cancelSettled: a cancel was requested — settle `aborted` immediately - // rather than waiting on a child that may ignore `session/cancel` or - // wedge the prompt (the `cancel()` contract: `result` settles `aborted`). - // A spawn error can only precede readiness and is already one arm of - // `started`; after `newSession` succeeds, transport/process failure rejects - // the in-flight prompt RPC through the connection. + // Race two post-start outcomes, first to settle wins: - prompt: the normal remote turn; - + // cancelSettled: a cancel was requested — settle `aborted` immediately rather than + // waiting on a child that may ignore `session/cancel` or wedge the prompt (the `cancel()` + // contract: `result` settles `aborted`). const prompt = async (): Promise => { // `started` cannot fulfill without assigning the session id; the cast // records that local invariant without an unreachable defensive arm. @@ -353,12 +292,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su ]) } catch (error: unknown) { if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } - // The seam contract: result resolves (never rejects) on a child-level - // failure. A cancellation is recognized by the flag above even when it - // wins during readiness; every other rejection is a genuine child-level - // error — initialize/newSession/prompt transport/RPC failure or ENOENT. - // Flatten to `error` and surface the original via onError so a real fault - // is preserved rather than silently lost. + // The seam contract: result resolves (never rejects) on a child-level failure. try { spec.onError?.(toError(error), 'error') } catch { @@ -379,15 +313,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) - // Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → - // SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the - // one that matters: our acp-agent has NO SIGTERM handler in a normal - // session — it tears down via the server bridge's connection-close path - // (conn.closed → per-agent dispose → final session/flush), driven by the - // stdin EOF, NOT by a signal — and a prompt response can resolve from a - // turn/end BEFORE that post-turn flush lands, so the child still has - // durable work owed when dispose runs (hence the wide EOF grace; see - // DEFAULT_DISPOSE_EOF_GRACE_MS). + // Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → SIGKILL, awaiting the + // actual exit). await disposeChildProcess(child, { disposeEofGraceMs: spec.disposeEofGraceMs, disposeGraceMs: spec.disposeGraceMs, diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 9cfeac1f44..753c4b5de1 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -1,44 +1,7 @@ /** - * A minimal mock ACP AGENT, run as a subprocess, for the keyless - * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is - * fully scripted by environment variables — no model, no network: - * - * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. - * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` - * (`end_turn` default, or `max_tokens`/`refusal`/…). - * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for - * a `session/cancel`), to exercise the client's cancel path. - * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives - * `session/cancel` but NEVER resolves the pending prompt - * and never exits — a non-cooperative child. The backend's - * `result` must still settle `aborted` on its own and - * `dispose()` must still kill the process. - * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` - * before answering, to exercise the client's auto-answer. - * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` - * handler is in flight (it has streamed its chunk). A test - * polls for this file to cancel on a CONDITION rather than - * an arbitrary timeout (subprocess cold-start is variable). - * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat - * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real - * acp-agent's EOF-driven quiesce+flush, then touches this - * path and exits ON ITS OWN — no signal. Stands in for a - * child whose durable flush completes only if dispose - * gives EOF a real window before escalating to SIGTERM. - * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare - * timer) but install a SIGTERM handler that exits (and, if - * MOCK_SIGTERM_FILE is set, touches it as an observable - * proof the SIGTERM rung fired). The child ignores the - * graceful EOF window yet dies cooperatively on SIGTERM — - * exercising dispose's middle tier (exit during the SIGTERM - * grace, before the SIGKILL escalation). Touches - * MOCK_READY_FILE once armed. - * - * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the - * child process the ACP backend drives. Kept as a `.ts` run under tsx by the - * spec (which passes its own tsconfig), mirroring how the snapshot harness boots - * the real example. - * + * A minimal mock ACP AGENT, run as a subprocess, for the keyless `dsh-subagent-acp` tests. It + * speaks the agent side of ACP over stdio and is fully scripted by environment variables — no + * model, no network. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ @@ -157,11 +120,8 @@ function makeAgent(conn: AgentSideConnection): Agent { process.exit(1) } if (IGNORE_CANCEL) { - // A NON-COOPERATIVE child: receive session/cancel but never resolve the - // pending prompt and never exit. The backend's `result` must still settle - // `aborted` on its own (the cancel-settle race), and `dispose()` must - // still kill the process — proving cancellation does not depend on the - // child cooperating. + // A NON-COOPERATIVE child: receive session/cancel but never resolve the pending prompt + // and never exit. return Promise.resolve() } resolveCancel?.('cancelled') @@ -178,12 +138,9 @@ new AgentSideConnection( ), ) -// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process -// neither quiesces on EOF nor dies on the graceful signal — exercising the -// backend dispose path's SIGKILL escalation. Without this the process exits -// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so -// a test waits for that CONDITION before disposing (the trap must be in place, -// not merely the process spawned — otherwise SIGTERM hits the default handler). +// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces +// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL +// escalation. if (process.env.MOCK_TRAP_SIGTERM === '1') { process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) // Keep the event loop alive (a bare timer) so nothing else lets it exit. @@ -191,13 +148,9 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') } -// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on -// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to -// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The -// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before -// the beat completes (no graceful window, or an EOF grace shorter than the -// flush) default-terminates this process and the marker is missing; a dispose -// that gives the EOF quiesce enough window first lets the flush land. +// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the +// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and +// exit ON OUR own — no signal involved. if (FLUSH_ON_EOF !== undefined) { const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { @@ -208,14 +161,7 @@ if (FLUSH_ON_EOF !== undefined) { }) } -// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF -// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the -// child ignores the graceful EOF window yet dies cooperatively on SIGTERM, -// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the -// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an -// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle -// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs -// and the marker is missing. Touch READY_FILE once armed (a test waits on it). +// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier. if (process.env.MOCK_IGNORE_EOF === '1') { const sigtermFile = process.env.MOCK_SIGTERM_FILE process.on('SIGTERM', () => { @@ -225,4 +171,3 @@ if (process.env.MOCK_IGNORE_EOF === '1') { setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000) if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed') } - diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 826ef198dd..9152702ea8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -9,16 +9,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import * as acp from '../src/index.ts' /** - * With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP - * server. The backend spawns the real `acp-agent` example as a child PROCESS, - * speaks ACP to it over stdio, and the child runs the REAL model in its own - * process to answer a prompt. We verify the child's real answer comes back - * through the seam — the "talk to our own process" smoke the design called for. - * Key-gated (self-skips without DEEPSEEK_API_KEY). - * - * This is the out-of-process analogue of the in-process spawn e2e: there a - * parent agent on the same context drove a child; here the child is a separate - * process reached over ACP, proving the seam generalizes across the boundary. + * With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP server. */ // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e25d194c98..a88fc3de64 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -188,9 +188,8 @@ describe('dsh-subagent-acp', () => { }) it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => { - // The child traps SIGTERM and keeps its event loop alive, so a graceful - // term alone would hang dispose forever. With a short grace, dispose must - // escalate to SIGKILL and return once the process is actually gone. + // The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would + // hang dispose forever. const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-')) const ready = join(tmp, 'trap-armed') try { @@ -224,15 +223,8 @@ describe('dsh-subagent-acp', () => { }) it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => { - // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears - // down on connection close, NOT on a signal) — and it has no SIGTERM handler. - // Its EOF teardown can itself await a signal-trapping grandchild (a bash - // subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window - // must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value. - // The mock models a flush that takes LONGER than the SIGTERM grace but well - // under the EOF grace: it lands only because tier 1 waits eofGraceMs, not - // graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the - // round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.) + // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on + // connection close, not on a signal) — and it has no SIGTERM handler. const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-')) const ready = join(tmp, 'ready') const flushed = join(tmp, 'flushed') @@ -242,10 +234,7 @@ describe('dsh-subagent-acp', () => { args: ['--import', tsxLoader, mockServer], cwd: process.cwd(), permission: 'reject', - // MOCK_HANG so the prompt never resolves on its own — we tear down a live - // child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits - // the 2000ms EOF grace; the marker lands iff the EOF tier honored its own - // wider grace. + // MOCK_HANG so the prompt never resolves on its own — we tear down a live child. env: { MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig, @@ -267,12 +256,9 @@ describe('dsh-subagent-acp', () => { }) it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { - // A child that keeps its loop alive past stdin EOF (so the graceful window - // times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier - // — dispose returns there, never reaching the SIGKILL tier. The child touches - // a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if - // dispose had skipped the middle rung (EOF→SIGKILL) the handler would never - // run and the marker would be absent — making this a GENUINE middle-tier guard. + // A child that keeps its loop alive past stdin EOF (so the graceful window times out) but + // exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there, + // never reaching the SIGKILL tier. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-')) const ready = join(tmp, 'ready') const sigterm = join(tmp, 'sigterm') @@ -307,9 +293,6 @@ describe('dsh-subagent-acp', () => { it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => { // Gate the child at newSession: it signals `ready` and blocks until `go`. - // We cancel WHILE newSession is pending (sessionId still undefined, so the - // backend cannot send session/cancel) — the `cancelled` flag alone must - // settle the run aborted after newSession resolves, never issuing the prompt. const tmp = mkdtempSync(join(tmpdir(), 'acp-early-')) const ready = join(tmp, 'ready') const go = join(tmp, 'go') @@ -457,10 +440,9 @@ describe('dsh-subagent-acp', () => { }) it('reports a flattened child failure through onError (preserved, not silently lost)', async () => { - // The seam forbids `result` rejecting, so a child-level failure is flattened - // to a stop reason — onError must still surface the original error so a real - // fault is logged, not swallowed. A nonexistent command triggers the spawn - // failure path; the spy records the error + the chosen stop reason. + // The seam forbids `result` rejecting, so a child-level failure is flattened to a stop + // reason — onError must still surface the original error so a real fault is logged, not + // swallowed. const errors: { message: string; stopReason: string }[] = [] const run = startAcpRun( { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, @@ -506,10 +488,8 @@ describe('dsh-subagent-acp', () => { }) it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => { - // The child hangs, we cancel, and instead of answering the child exits hard - // — the pending prompt RPC rejects. With a cancel already requested, the - // backend's catch path must settle `aborted` (the failure is the cancel - // surfacing as a torn pipe), not `error`. + // The child hangs, we cancel, and instead of answering the child exits hard — the pending + // prompt RPC rejects. const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-')) const ready = join(tmp, 'ready') try { @@ -526,10 +506,7 @@ describe('dsh-subagent-acp', () => { }) it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => { - // The contract: run.cancel() → result settles `aborted`. A child that hangs - // its prompt AND ignores session/cancel must not wedge the parent — the - // backend's own cancel-settle path resolves `aborted` without the child's - // cooperation, and dispose() still reaps the process. + // The contract: run.cancel() → result settles `aborted`. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-')) const ready = join(tmp, 'ready') try { diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 1434601c8e..8ef79e9001 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,23 +1,17 @@ # @deepseek-ai/dsh-subagent-fork -The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. +In-process provider that starts a child [`Agent`](../../core/agent) from the parent's completed conversation prefix. It shares [`startInProcessRun`](../subagent-inprocess/README.md) with the [spawn provider](../subagent-spawn/README.md); the seed is the only backend difference. -## The seed boundary (the crux) +## Seed boundary -At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**. - -So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child. - -The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses. +The delegating tool runs inside an open parent turn whose tool call has no result yet. Forking that tail would create an invalid, unbalanced child log, so the provider copies only the prefix through the last `turn/end`. A first-turn fork therefore starts with an empty seed. `CreateAgentOptions.seed` carries the contiguous prefix into session preparation. ## Capabilities -`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior. +`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` ## Config | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `fork`). | - -See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 3b9243aa2e..387ed359cb 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -1,22 +1,8 @@ /** * The in-process FORK subagent backend: registers a {@link SubagentProvider} on - * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a - * prefix of the parent's session log — so the child inherits the parent's - * conversation context instead of starting fresh. The run mechanics live in - * `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this - * backend just computes the seed. The spawn backend is an independent peer over - * the same driver. - * - * The seed boundary is the crux: at the moment a subagent tool's `execute` - * runs, the parent's CURRENT turn is open and unbalanced (it holds the - * `assistant/message` with this spawn's tool-call, plus the dangling `tool/call` - * with no `tool/result`). Seeding that raw prefix gives the child an open turn - * the session constructor and the dev-mode invariants replay REJECT. So the - * fork seeds only the **balanced completed-turn prefix**: the parent's log up - * to and including its last `turn/end`, excluding the in-flight turn entirely. - * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. - * + * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the + * parent's session log — so the child inherits the parent's conversation context instead of + * starting fresh. * @module @deepseek-ai/dsh-subagent-fork */ @@ -45,12 +31,9 @@ export const Config: z = z.object({ }) /** - * The balanced completed-turn prefix of `parent`'s log: every event up to and - * including the last `turn/end`. Empty if the parent has never completed a turn - * (the in-flight turn is excluded, so a parent on its very first turn forks an - * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live - * log keeps `seq === index`), so it is a valid session seed; the in-flight, - * unbalanced turn is dropped so the invariants replay accepts it. + * The balanced completed-turn prefix of `parent`'s log: every event up to and including the + * last `turn/end`. + * * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f9c7b04a51..6e726e7350 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -132,10 +132,8 @@ describe('dsh-subagent-fork', () => { }) it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => { - // Drive the parent so it has ONE completed turn, then start a SECOND turn - // that is still open (a hanging model call), and fork while it's in flight. - // The fork must seed only the completed first turn — an unbalanced seed - // would make the invariants replay throw inside ctx.subagents.start. + // Drive the parent so it has one completed turn, then start a SECOND turn that is still + // open (a hanging model call), and fork while it's in flight. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) parent.send([{ type: 'text', text: 'q1' }]) await parent.whenIdle() @@ -180,12 +178,7 @@ describe('dsh-subagent-fork', () => { }) it('does NOT return the seeded parent output when the child produces no message of its own', async () => { - // Regression: readResult must scope to the child's OWN events (after the - // seed). The parent completes a turn with a distinctive assistant message, - // then the fork child's own turn finishes with a bare `stop` and NO - // assistant/message. Scanning the whole (seeded) log would return the - // parent's "parent stale" message with stopReason 'completed'; scoped to the - // child's own events the output is empty. + // Regression: readResult must scope to the child's own events (after the seed). const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) parent.send([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index caa7b9b161..65cff2495d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -1,39 +1,25 @@ # @deepseek-ai/dsh-subagent-inprocess -The shared **in-process subagent run driver**. A library with no provider or import-time registration that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. Each accepted run installs one provider-owned cleanup effect. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other. +Shared run driver for the in-process [spawn](../subagent-spawn/README.md) and [fork](../subagent-fork/README.md) providers. It creates a child agent on the same Cordis application; the providers differ only in the optional session seed. -## What it exports +## `startInProcessRun(ctx, request, options)` -### `startInProcessRun(ctx, request, options): SubagentRun` +The driver snapshots mutable request data, checks delegation depth, and creates one run-owner fiber under the parent. Parent teardown, provider teardown, manual disposal, and cancellation during creation converge on that owner. -Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): +Child creation uses fresh IDs, lineage, an inherited or overridden model, and an unpublished setup callback for persona, tool restriction, and structured output. `run.started` resolves after the child is published. The result path sends one prompt, waits for idle, and derives output only from events after the seed boundary; a seeded parent answer cannot become the child's result. -1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; -2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; -3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; -4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). +`dispose()` awaits creation or rollback and then the child handle's quiescent disposal. `cancel()` records pre-publication cancellation and applies it when the child exists. A cancelled attempt with no completed turn reports `aborted`. -`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`InProcessRunOptions` is `{ seed?: SessionEvent[] }`: absent for spawn and the completed-turn prefix for fork. -### `InProcessRunOptions` +## Structured output -`{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork. +`attachStructuredRuntime(childCtx, schema)` installs a child-scoped capture tool, prompt instruction, protection, result observer, guard, and terminal turn policy. The actual schema is registered only for that child. -### Structured output (package-internal runtime) +A validated value is staged by immutable execution identity and committed only after the authoritative `tools/result` succeeds. Code Mode also waits for the enclosing `run_code` result. Once pending or committed, later tool calls are denied; after commit, `agent/turn-stop` prevents another model step. A child that finishes without a committed value reports an error. -`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state): +## Depth -- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object; -- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent); -- a scoped `systemPrompt.protect()` registration making the capture instruction and schema canonical after the complete assembly waterfall. Canonical absence is protected too: pure Code Mode removes `structured_output` from the wire and declares it through the SDK. The tool registry separately owns and protects its `tools:sdk` section and reserved `run_code` transport; protection guarantees those named contributions, while unrelated listener-added schemas remain the listener's responsibility. The loop logs the finalized assembly as the step's `request/header`, so the demand remains reconstructable; -- a scoped `tools/result` observer as the commit point: it promotes a staged value only when that same execution's immutable, JSON-safe authoritative result after the complete pre-execute → guards → execute → post-execute pipeline succeeds. For a Code Mode SDK sub-dispatch, the child's opaque `parent` token matches the enclosing `run_code` execution's registry-assigned `token`, so promotion waits for that outer final result without exposing its live object; a runtime failure or post-policy block discards the value. Execution-object identity prevents call-id reuse or another execution from reaching the stage; -- a scoped monotonic `tools.guard()` denial for every call arriving after capture. Guards run after the extensible pre-execute waterfall and cannot return allow, so terminal means terminal within the step regardless of listener order; -- a scoped `agent/turn-stop` terminal policy stopping the child's turn once its output is captured. It runs after ordinary continuation and steering folding, and its terminal state survives turn close and flush, so later listeners cannot leak steering into another step or turn; ordinary queued prompts remain intact. +`depthOf(agent)` reads merge-extensible `AgentOptions.subagentDepth` (default `0`). `startInProcessRun` throws `SubagentDepthError` when the next depth exceeds `maxDepth`. -### `depthOf(agent): number` - -Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0). - -### `SubagentDepthError` - -Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. +See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for ownership and final-policy rationale. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index de3b042fe3..8e92ff4399 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,17 +1,7 @@ /** - * The shared in-process subagent run driver: run a child as a child - * {@link Agent} on the SAME cordis context (`ctx.agents`) — the cheapest - * transport, reusing the agent factory's quiescent {@link AgentHandle} - * teardown. The concrete in-process backends are thin shells over this driver, - * differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with - * a prefix of the parent's log); everything downstream — drive the child, read - * its final output, map the stop reason, dispose — is identical and lives here. - * - * This package declares no provider and performs no import-time registration; - * it is a library the backend packages depend on, so neither backend needs to - * know about the other. Each accepted run does install one provider-owned - * effect for structured-concurrency cleanup. - * + * The shared in-process subagent run driver: run a child as a child {@link Agent} on the same + * cordis context (`ctx.agents`) — the cheapest transport, reusing the agent factory's + * quiescent {@link AgentHandle} teardown. * @module @deepseek-ai/dsh-subagent-inprocess */ @@ -75,9 +65,8 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { return 'max-tokens' case 'aborted': return 'aborted' - // `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean - // the turn did not finish cleanly; surface them as a generic failure rather - // than a clean completion. A missing reason (no turn ran) is also an error. + // `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean the turn did + // not finish cleanly; surface them as a generic failure rather than a clean completion. case 'error': case 'disposed': case 'interrupted': @@ -104,16 +93,6 @@ async function quiesceFiber(fiber: Fiber): Promise { /** * Start an in-process child agent for `request` and return a {@link SubagentRun}. * - * Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering - * matters — `send` enqueues synchronously, so `whenIdle` observes the queued - * work and resolves only on the child's `running → idle` transition, never - * before the turn starts). The final `assistant/message` is the result output, - * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the - * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove - * session); `cancel()` cancels the child's in-flight turn. - * - * Throws {@link SubagentDepthError} before creating anything when the child's - * depth (parent depth + 1) would exceed `request.maxDepth`. * @param ctx - the provider context that owns the live run as a second * structured-concurrency boundary alongside the parent agent. * @param request - the start request (prompt, parent, signal, per-child options). @@ -137,22 +116,11 @@ export function startInProcessRun( if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } - // Assert, then snapshot, the schema subset BEFORE any child exists (the - // service has already capability-gated; this rejects a schema outside the - // enforced subset loud). Assertion comes FIRST so a hostile value fails as - // OutputSchemaError, never as structuredClone's raw DataCloneError — the - // asserted subset is plain JSON data, which always clones. The snapshot is - // load-bearing: the caller keeps its reference, so attaching the ORIGINAL - // would let a post-start() mutation drift the enforced schema away from the - // asserted one — the clone (taken synchronously with the assertion, no - // interleaving possible) pins assertion, the model-visible parameters, and - // validateStructuredValue to one isolation-immutable value. + // Assert, then snapshot, the schema subset before any child exists (the service has already + // capability-gated; this rejects a schema outside the enforced subset loud). if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) - // The accepted request owns a value snapshot, not the caller's mutable - // content array. Validate the same lossless-JSON contract Session.append - // enforces before any child exists, then detach it synchronously so mutation - // during async creation cannot change what is logged or sent to the model. + // The accepted request owns a value snapshot, not the caller's mutable content array. if (!isJsonValue(request.prompt)) { throw new TypeError('subagent prompt must be losslessly JSON-serializable') } @@ -168,24 +136,15 @@ export function startInProcessRun( // SEEDED parent's last assistant message as its result. const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header - // Inherit the parent's model by default (a child with no model cannot run); - // an explicit `request.agentOptions.model` overrides it. The deployment - // persona needs no inheritance (a context-wide section both render); a - // per-child `request.persona` becomes a SCOPED section of the same name in - // the setup below, shadowing the deployment's for this child alone. + // Inherit the parent's model by default (a child with no model cannot run); an explicit + // `request.agentOptions.model` overrides it. const agentOptions: AgentOptions = structuredClone({ ...parent.options.model !== undefined ? { model: parent.options.model } : {}, ...request.agentOptions, subagentDepth: childDepth, }) - // The child's scoped world, composed in the factory's unpublished setup - // window. The factory awaits it before inserting or announcing the child, so - // a throw/rejection exposes neither id and every first assembly sees it: - // - persona: a scoped `deployment:persona` section shadowing the global one; - // - toolFilter: a scoped restrict() masking the global tool surface - // (loud unknown-name validation lives in the registry); - // - outputSchema: the structured runtime, attached as scoped registrations. + // The child's scoped world, composed in the factory's unpublished setup window. let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { if (persona !== undefined) { @@ -199,15 +158,8 @@ export function startInProcessRun( } } - // Bridge the request's abort signal to the child (the consumer also bridges - // its own exec.signal, but a backend-level bridge keeps the contract local). - // Install it after provider ownership succeeds but BEFORE awaiting creation, - // so an inactive provider cannot leave an orphaned listener and abort/dispose - // during async setup is still recorded and applied the moment a child exists. - // `cancelled` records that a cancel was requested at all, so the pre-turn - // cancel window — where the child clears the queued prompt before any - // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) - // rather than falling through to the no-turn `error` mapping. + // Bridge the request's abort signal to the child (the consumer also bridges its own + // exec.signal, but a backend-level bridge keeps the contract local). let cancelled = false // An accessor, not an inline read: `cancelled` mutates from closures (the // abort listener, run.cancel), which control-flow narrowing cannot see — an @@ -223,13 +175,7 @@ export function startInProcessRun( } const onAbort = (): void => { requestCancel('subagent cancelled') } - // One run-owned Cordis fiber is the common ownership node. Install the - // provider effect FIRST: a start racing an already-unloading provider fails - // before it can mint anything under the parent. The owner fiber is then - // nested under the parent scope, and the provider/run handle both dispose - // this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of - // the three owners moves the fiber out of ACTIVE synchronously and setup - // cannot publish afterward. + // One run-owned Cordis fiber is the common ownership node. let ownerCtx: Context | undefined function subagentRunOwner(inner: Context): void { ownerCtx = inner } let ownerFiber: (Fiber & PromiseLike) | undefined @@ -264,12 +210,7 @@ export function startInProcessRun( if (ownerCtx === undefined) { throw new Error('subagent run owner became inactive before child creation') } - // Invoke the factory THROUGH the parent scope. Cordis binds the factory's - // lifecycle effect to the accessing context, so parent ownership exists - // before persistence/setup and publication—not as a fallible link added - // after the child is already visible. A disposed parent therefore rejects - // before any session/agent notification, and disposal during async setup - // wins the unpublished transaction. + // Invoke the factory THROUGH the parent scope. const created = await ownerCtx.agents.create({ agentId: childId, sessionId: SessionId(randomUUID()), @@ -289,12 +230,7 @@ export function startInProcessRun( return created.agent })() - // Provider readiness is a distinct lifecycle boundary from accepting the - // request. It resolves only after the factory has published the child and - // returned its handle, so SubagentService can emit `subagent/start` while - // `ctx.agents.get(childId)` is guaranteed to resolve. The result path awaits - // THIS SAME promise immediately, which also observes a readiness rejection - // when the driver is invoked directly rather than through SubagentService. + // Provider readiness is a distinct lifecycle boundary from accepting the request. const started: Promise = creation.then(() => undefined) const result: Promise = (async () => { @@ -356,22 +292,10 @@ export function startInProcessRun( } /** - * Read a settled child's terminal result from its session log, scoped to the - * child's OWN events (everything at or after `seedLength` — fork seeds the - * parent's completed-turn prefix, so a child that produced no message of its - * own must NOT return the seeded parent's last assistant message). The output - * is the child's last `assistant/message` content (deep-cloned — the log is - * frozen); the stop reason is the child's last `turn/end` reason mapped to a - * {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was - * logged (a cancel landed in the pre-turn window, before any turn ran), the - * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than - * the generic no-turn `error`. - * - * A structured run (`structured` present) additionally reports the captured - * value on {@link SubagentResult.structured}. A structured child that finished - * CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean - * finish without the demanded structured result is a failure, not a success - * with a missing field; a non-`completed` reason keeps its own honest mapping. + * Read a settled child's terminal result from its session log, scoped to the child's own + * events (everything at or after `seedLength` — fork seeds the parent's completed-turn prefix, + * so a child that produced no message of its own must not return the seeded parent's last + * assistant message). */ function readResult( child: Agent, diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index dff63486ed..b2bbc460e6 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -1,45 +1,6 @@ /** - * Structured-output support for the in-process subagent backends: the - * mechanism behind `SubagentStartRequest.outputSchema` for children that run - * as agents on the same context. - * - * Everything is a SCOPED registration on the child agent's context - * (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool - * carries the run's REAL schema as its registered parameters (each child sees - * exactly its own schema — two concurrent structured runs never interact), the - * demand instruction is an ordinary order-190 scoped section, and the - * enforcement listeners fire only for this child (scope-filtered dispatch). - * Registration lifetime rides the child's fiber, so a backend hot-reload - * mid-run cannot unregister the capture tool out from under a live child, and - * a disposed child leaves no residue — no placeholder schema, - * strip-for-everyone-else pass, or refcounted global runtime. - * - * The child scope's registrations enforce the contract: - * - * - `systemPrompt.protect()` declaratively protects the capture tool and its - * instruction. The service restores their canonical pre-waterfall state - * after EVERY assembly listener. Canonical absence is protected too: pure - * Code Mode keeps `structured_output` in the SDK only and never grows a - * second native wire tool. Code Mode's owner independently protects its SDK - * and `run_code` transport. The loop logs the finalized assembly as the - * request header, so the demand is reconstructable log state, never a - * wire-only mutation. - * - `agent/turn-stop` (serial, scoped): stop the child's turn once its output - * is captured. This terminal checkpoint runs after the ordinary continuation - * waterfall and steering folding, so listener order cannot resurrect a - * completed structured run or carry terminal steering into another turn. - * - `tools.guard()` is the monotonic terminal gate after the extensible - * pre-execute waterfall: once capture commits, no later listener can turn - * the denial back into a dispatched side effect. - * - `tools/result` is the capture COMMIT point. The tool body only STAGES the - * validated value in a WeakMap keyed by the execution object; the awaited, - * non-transforming notification promotes it only when the authoritative - * result after the whole pre/execute/post pipeline succeeds. For a Code Mode - * sub-dispatch, promotion waits again for the enclosing `run_code` result, so - * a runtime failure or outer post-policy block cannot report structured - * success. Execution identity makes call-id reuse and orphaned stages - * irrelevant. - * + * Child-scoped structured-output capture. Values commit only after the final + * tool outcome; guards and terminal turn policy prevent work after capture. * @module @deepseek-ai/dsh-subagent-inprocess/structured */ @@ -52,11 +13,7 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f /** The model-facing tool name a structured child must call to finish. */ export const STRUCTURED_OUTPUT_TOOL = 'structured_output' -/** - * The instruction registered as the child's trailing (order-190, the end of - * the tool-guidance band) scoped prompt section: the demand travels with the - * tool, as ordinary prompt state of exactly one agent. - */ +/** Prompt instruction paired with the child-scoped capture tool. */ export const STRUCTURED_OUTPUT_INSTRUCTION = 'When you have your final answer, you MUST report it by calling the ' + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` @@ -64,35 +21,18 @@ export const STRUCTURED_OUTPUT_INSTRUCTION /** One structured run's live handle: read the captured value once the child settles. */ export interface StructuredAttachment { - /** - * The captured value, once the child called the tool with valid arguments - * and the authoritative final tool result accepted that call. - * @returns the committed value, or undefined while none was accepted. - */ + /** @returns the committed value, or `undefined` until one is accepted. */ captured(): { value: unknown } | undefined } /** - * Attach the structured-output runtime to a child for `schema`: register the - * scoped capture tool (real schema), the scoped instruction section, and the - * scoped enforcement registrations (see the module doc). Call from the - * agent-creation `setup` window with the child's scope context — every - * registration rides the child's fiber and unwinds with the child. - * @param childCtx - the child agent's scope context (`setup`'s argument). - * @param schema - the isolation-cloned, already-asserted schema subset to - * enforce (see `assertSupportedOutputSchema` in dsh-tools). - * @returns the attachment handle (read `captured()` after the child settles). + * Install structured-output capture in a child's setup scope. + * @param childCtx - child agent scope context. + * @param schema - validated schema enforced by the capture tool. + * @returns handle for reading the committed value after settlement. */ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { - /** - * Validated values staged by the capture tool body, awaiting THEIR OWN - * authoritative `tools/result` notification. The execution object's identity - * uniquely identifies a trip through the pipeline: adapter call ids may - * repeat across steps, but another execution can never reach this WeakMap - * entry. This is distinct from the opaque `ToolExecutionToken` used to - * correlate nested transports. The final notification always deletes its own - * stage, whether the result succeeded or failed. - */ + // Stages are keyed by pipeline identity, not reusable model call ids. const staged = new WeakMap() /** Successful nested capture waiting for its enclosing transport to commit. */ let pending: { parent: ToolExecution['token']; value: unknown } | undefined @@ -103,8 +43,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut description: 'Report your final structured result. Call this exactly once, when your answer is complete; ' + 'the arguments must match this tool\'s parameter schema exactly.', - // ToolSchema.parameters is the wire-level JSON Schema object; the - // asserted subset type is structurally exactly that. + // The validated subset is a wire-level JSON Schema object. parameters: schema as unknown as Record, } @@ -112,12 +51,8 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut ...schemaEntry, execute(args: unknown, exec: ToolExecution): Promise { const violations = validateStructuredValue(schema, args) - // ToolArgsError → isError result with INVALID_ARGS: the model retries - // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) - // Two-phase commit, keyed by THIS execution: later transformable - // waterfalls may still turn the success into an error. Snapshot the - // validated value independently of the already-frozen pipeline arguments. + // Commit waits for this execution's final result. staged.set(exec, { value: structuredClone(args) }) return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) }, @@ -129,34 +64,21 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut text: STRUCTURED_OUTPUT_INSTRUCTION, }) - // Service-owned finalization, not waterfall ordering. The canonical - // assembly determines both presence and absence: native/both modes restore - // the capture schema on the wire, while pure Code Mode removes any injected - // native entry. ToolRegistry's own protection independently restores the SDK - // section and run_code transport that carry the same schema. + // Protection preserves the mode-appropriate canonical presence or absence. childCtx.systemPrompt.protect({ sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`], tools: [STRUCTURED_OUTPUT_TOOL], }) - // Stop the child's turn once its output is captured. This monotonic serial - // checkpoint runs after the ordinary continuation waterfall, its reason, - // and late-steering folding, so no ordering trick can resume a finished run. childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined { return captured === undefined ? undefined : { action: 'stop' } }) - // Terminal WITHIN the step. Guards run after the whole pre-execute - // waterfall and compose monotonically (deny or abstain, never allow), so a - // later prepended listener cannot resurrect dispatch. Calls that precede - // capture in the same response remain untouched. + // Calls earlier in the same response remain valid; later calls are terminally denied. childCtx.tools.guard(exec => captured === undefined && pending === undefined ? undefined : `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`) - // The capture COMMIT observes the immutable, authoritative result after the - // complete pipeline and outer error normalization. This notification cannot - // transform the outcome, so there is no wrapper outside the commit verdict. childCtx.on('tools/result', function (this: unknown, exec, result): void { if (exec.name === STRUCTURED_OUTPUT_TOOL) { const entry = staged.get(exec) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1e63617154..57bd654505 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -236,11 +236,7 @@ describe('in-process structured output', () => { ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = ctx.subagents.start('spawn', structuredRequest(parent)) let wrapperInstalled = false - // Register this observer only after start() returns. The child session-start - // boundary is after its unpublished setup attached structured output but - // before the loop can run; install a prepended wrapper there. It awaits the - // explicit downstream stop above, then overwrites that result with continue. - // The later terminal checkpoint still wins. + // Register this observer only after start() returns. ctx.on('agent/session-start', (child) => { if (child.id !== run.id) return wrapperInstalled = true @@ -263,10 +259,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), textResponse('MUST NOT BE CONSUMED'), ]) - // The downstream ordinary policy says stop. A wrapper registered after - // start() delegates to that stop, then queues steering; ordinary folding - // would turn the stop back into continue. The terminal checkpoint runs - // afterwards and discards that steering. + // The downstream ordinary policy says stop. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index e411d44ab0..20de6648cd 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -1,16 +1,12 @@ # @deepseek-ai/dsh-subagent-spawn -The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown. +In-process provider that runs each request as a fresh child [`Agent`](../../core/agent) on the same Cordis application. The child has a new session and no inherited conversation; it uses the parent model unless overridden. -The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other. - -## What it does - -`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +The package delegates lifecycle work to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed. Child creation, persona, tool filtering, structured output, cancellation, and quiescent disposal are owned by the shared driver. `run.started` resolves only after publication, so `subagent/start` observers can resolve the child from `ctx.agents`. ## Capabilities -`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope. +`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` ## Config diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 835cf34aae..71b7d8133e 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -1,21 +1,8 @@ /** - * The in-process SPAWN subagent backend: registers a {@link SubagentProvider} - * on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the - * same cordis context (its own session, own system prompt, zero parent - * context). The cheapest transport, reusing the agent factory's quiescent - * teardown. - * - * The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess` - * ({@link startInProcessRun}); this backend just passes NO seed (a fresh - * child). The fork backend is an independent peer over the same driver. - * - * Structured output (`outputSchema`) is supported through the driver's - * per-child scoped runtime: the child registers its real-schema capture tool, - * prompt instruction, and enforcement listeners inside the creation setup - * window, and its scope owns their lifetime. - * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. - * + * The in-process SPAWN subagent backend: registers a {@link SubagentProvider} on + * `ctx.subagents` that runs each child as a fresh child {@link Agent} on the same cordis + * context (its own session, own system prompt, zero parent context). The cheapest transport, + * reusing the agent factory's quiescent teardown. * @module @deepseek-ai/dsh-subagent-spawn */ @@ -25,10 +12,8 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' -// `tools` is deliberately NOT injected: the shared driver registers structured -// output through the child's creation context, whose factory already requires -// the tool service. Keeping it out of this backend's inject list preserves the -// provider's independent apply timing. +// `tools` is deliberately not injected: the shared driver registers structured output through +// the child's creation context, whose factory already requires the tool service. export const inject = ['subagents'] /** Config: the registry name to register the provider under. */ diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 3d23fca285..887c617a93 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -148,11 +148,8 @@ describe('dsh-subagent-spawn', () => { }) it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => { - // Regression: a signal aborted BEFORE the run starts never fires an `abort` - // event, so the listener can't catch it. The driver must check the - // already-aborted case up front and settle `aborted` without running the - // child — otherwise an already-cancelled request runs to `completed`. The - // empty script proves the child's model is never called. + // Regression: a signal aborted before the run starts never fires an `abort` event, so the + // listener can't catch it. const controller = new AbortController() controller.abort() const { ctx, parent } = await setup([]) @@ -164,12 +161,8 @@ describe('dsh-subagent-spawn', () => { }) it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { - // Regression: a cancel landing in the pre-turn window clears the queued - // prompt before any `turn/end` is logged. Deriving the stop reason from - // `turn/end` alone then mis-maps the no-turn case to `error`; the run must - // honor the cancel contract and settle `aborted`. The cancel is synchronous - // (same tick as start, before the loop's queued-wait continuation runs), so - // the turn is dropped and the empty script is never consumed. + // Regression: a cancel landing in the pre-turn window clears the queued prompt before any + // `turn/end` is logged. const { ctx, parent } = await setup([]) const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) run.cancel('early') @@ -345,9 +338,7 @@ describe('dsh-subagent-spawn', () => { parent, outputSchema: { type: 'object', properties: { a: { type: 'number' } } }, }) - // Let the child's step start streaming, then unload the backend. The - // backend owns the child agent, so the unload tears the child down and - // the run settles — releasing its own runtime acquisition on the way out. + // Let the child's step start streaming, then unload the backend. await new Promise(resolve => setTimeout(resolve, 30)) await fiber.dispose() const result = await run.result diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 35d7383456..ddc5b3f769 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -1,20 +1,7 @@ /** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn - * an external agent as a child process and must keep the parent deployment's - * credentials out of it, tear it down to quiescence, and isolate it from the - * host user's on-disk CLI state. The pieces: the credential env scrub - * ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure - * capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} / - * {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder - * ({@link disposeChildProcess}), and the per-run isolated config dir - * ({@link createIsolatedConfigDir}). - * - * This package owns no provider and registers nothing; it is a pure library - * the out-of-process backend packages depend on (the `subagent-inprocess` - * shape, for the process boundary). Every tunable — the ladder's grace - * periods, a pinned config dir — is a PARAMETER here: defaults belong in each - * consuming plugin's Config, per the no-hardcoded-tunables rule. - * + * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external + * agent as a child process and must keep the parent deployment's credentials out of it, tear + * it down to quiescence, and isolate it from the host user's on-disk CLI state. * @module @deepseek-ai/dsh-subagent-subprocess */ @@ -52,11 +39,8 @@ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv } /** - * Capture the child's spawn-level failure as a promise the run's result path - * can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an - * `error` EVENT, not a thrown exception — and without a listener Node treats - * it as an unhandled error and crashes the parent process. Call this in the - * SAME TICK as `spawn()`, so no window exists for the event to fire unheard. + * Capture the child's spawn-level failure as a promise the run's result path can race. + * * @param child - the just-spawned child process. * @returns a promise that RESOLVES (never rejects) with the child's first * `error` event; for a child that spawns cleanly it never settles. @@ -125,15 +109,8 @@ export interface DisposeLadderGraces { } /** - * Tear a child process down to QUIESCENCE: resolves only once the child has - * actually exited (or was already gone), never merely after requesting it. - * Three-tier escalation — - * - * 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a - * cooperative child quiesces on its own, its teardown and flushes intact; - * 2. `SIGTERM`, then wait `disposeGraceMs`; - * 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF - * and traps `SIGTERM` must not wedge dispose forever. + * Tear a child process down to QUIESCENCE: resolves only once the child has actually exited + * (or was already gone), never merely after requesting it. Three-tier escalation — * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. @@ -141,10 +118,7 @@ export interface DisposeLadderGraces { export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { // Already gone: nothing to reap. if (child.exitCode !== null || child.signalCode !== null) return - // 1. Graceful: end the request stream (stdin EOF) and let the child quiesce - // on its own. Sending SIGTERM in the same tick (or too soon) would - // default-terminate a cooperative child mid-flush, orphaning its nested - // work. A child spawned without a stdin pipe skips straight to the wait. + // 1. child.stdin?.end() if (await exitsWithin(child, graces.disposeEofGraceMs)) return // 2. SIGTERM, escalating if the child still does not exit within the grace. @@ -173,16 +147,9 @@ export interface IsolatedConfigDir { } /** - * An isolated config dir for one child run, so the child's behavior is a - * function of deployment config alone — never of whatever `~/.claude` / - * `~/.codex`-style state happens to exist on the host machine. Two modes: - * - * - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp` - * dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it - * best-effort; - * - `pinnedPath` set (a deployment deliberately sharing state across runs): - * the pinned path is returned as-is — never created, never removed — the - * deployment owns that directory's lifecycle. + * An isolated config dir for one child run, so the child's behavior is a function of + * deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state happens to + * exist on the host machine. Two modes. * * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. @@ -209,10 +176,8 @@ export async function createIsolatedConfigDir(prefix: string, pinnedPath?: strin try { await rm(path, { recursive: true, force: true }) } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — - // e.g. the dead child left an unreadable entry behind). The dir lives - // under the OS temp root, which reclaims it; failing dispose over - // cleanup would be worse than a leftover temp dir. + // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead + // child left an unreadable entry behind). } }, } diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 2766ed0a41..78b7efd1c8 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -15,11 +15,8 @@ import { waitForExit, } from '../src/index.ts' -// `rm` is wrapped (real-passthrough by default) so ONE test can inject a -// rejection deterministically. A real recursive-rm failure is not portably -// provokable — permission tricks (a chmod-000 subtree) fail only for -// unprivileged users and are ignored by root — so this is the fs boundary -// the testing policy sanctions mocking; everything else stays the real fs. +// `rm` is wrapped (real-passthrough by default) so one test can inject a rejection +// deterministically. vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, rm: vi.fn(actual.rm) } diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e214ba58c6..531c5e5bcc 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -34,7 +34,7 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` `provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface. +The service emits provider-added and provider-removed after registry changes, so consumers track membership without assuming sibling load order. A run emits `subagent/start` only after readiness and `subagent/end` only after that announced run settles; readiness rejection emits neither. Both are observe-only. Result settlement is observed immediately, cloned, and buffered until start to prevent unhandled rejection, preserve start-before-end ordering, and isolate listener mutation. Settled output appears as `lastAssistantMessage`; infrastructure rejection omits it. Remote providers need not publish a local agent. ## Scope (first cut) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index e1f4fac594..556b33d459 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -1,34 +1,8 @@ /** - * The subagent seam (`ctx.subagents`): a named-provider registry plus a - * capability-validating `start` surface. A subagent is an agent delegating - * work to another agent; a {@link SubagentProvider} is one transport for - * running that child (in-process spawn/fork, ACP to another process, and — - * later — A2A, the Codex app-server, the Claude Code Agent SDK). - * - * Unlike the bash seam (one executor per context, second load throws), MULTIPLE - * providers coexist here: each registers under a unique name and a caller picks - * one by name. The shape mirrors the LLM adapter registry - * (`LlmService.registerAdapter`), not the single-service bash executor. - * - * This package is the INTERFACE third of the capability seam. Implementations - * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing - * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. - * - * Scope (first cut): the consumer collects synchronously — it starts a run and - * awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage}) - * is part of the contract but intentionally unused; background / poll / spill - * semantics are deferred to a future redesign that unifies long-running-tool - * handling across subagents and bash. - * - * The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY - * payload; `subagent/end` additionally carries the child's `lastAssistantMessage` - * — see `docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md`. - * FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited - * waterfall returning a stop/continue decision, like the other interception - * seams) would require reshaping this emit into a waterfall, awaiting listeners - * before settling, and a `resume` capability on the in-process provider — part - * of the deferred background/steering redesign, NOT this observe-only cut. - * + * The subagent seam (`ctx.subagents`): a named-provider registry plus a capability-validating + * `start` surface. A subagent is an agent delegating work to another agent; a {@link + * SubagentProvider} is one transport for running that child (in-process spawn/fork, ACP to + * another process, and — later — A2A, the Codex app-server, the Claude Code Agent SDK). * @module @deepseek-ai/dsh-subagent */ @@ -85,16 +59,10 @@ declare module 'cordis' { */ 'subagent/provider-removed'(name: string): void /** - * A subagent run started — emitted only after {@link 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 - * {@link 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 {@link SubagentRun.started} fulfills, when + * the provider has established a live child. + * + * Scope-filtered dispatch: keyed to the delegating parent. * @param info - which provider started which child agent. * @mode emit */ @@ -104,10 +72,8 @@ declare module 'cordis' { * resolves (any stop reason) or rejects (reported as `error`). Paired with * {@link 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. + * Dispatch is scoped to the delegating parent. + * Scope-filtered dispatch: keyed to the delegating parent. * @param info - the run identity plus stop reason and final output. * @mode emit */ @@ -165,16 +131,8 @@ export class SubagentService extends Service { } /** - * Register a provider under its `provider.name`. Throws {@link SubagentError} - * (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots - * the name, static descriptors, and `start` callback identity at acceptance; - * later caller mutation cannot change lookup, capability validation, consumer - * wording, dispatch, or HMR cleanup. The callback remains bound to the - * original provider object, so provider-owned mutable state stays live. - * Effect-scoped: disposed with the calling fiber (HMR-safe). Emits - * `subagent/provider-added` after the registration and - * `subagent/provider-removed` on unregistration, so consumers can mirror - * provider lifecycle instead of assuming load order. + * Register a provider under its `provider.name`. + * * @param provider - the provider; its `name` is the registry key. * @returns the disposer that unregisters the provider. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -182,10 +140,6 @@ export class SubagentService extends Service { */ registerProvider(provider: SubagentProvider): () => Promise | void { // Snapshot the accepted registration contract before entering the effect. - // Cleanup must never re-read caller-owned `provider.name`: an HMR host may - // mutate or reuse the provider object before its old fiber unloads. Binding - // preserves the provider method's receiver while making replacement of the - // public callback field after registration inert. const capabilities: SubagentCapabilities = Object.freeze({ outputSchema: provider.capabilities.outputSchema, depthLimit: provider.capabilities.depthLimit, @@ -203,24 +157,16 @@ export class SubagentService extends Service { throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER') } this.providers.set(snapshot.name, snapshot) - // Yield the rollback BEFORE emitting `subagent/provider-added`: a - // throwing added-listener then unregisters the provider (and announces - // the removal) instead of leaking it into the registry. The removal - // announcement itself is contained PER LISTENER ({@link emitLifecycle}): - // it runs inside this disposer, where a propagating subscriber would - // disrupt the backend fiber's teardown and starve later mirrors. + // Yield the rollback before emitting `subagent/provider-added`: a throwing added-listener + // then unregisters the provider (and announces the removal) instead of leaking it into + // the registry. yield () => { this.providers.delete(snapshot.name) this.emitLifecycle('subagent/provider-removed', snapshot.name) } this.ctx.emit('subagent/provider-added', snapshot) }.bind(this), 'subagents.registerProvider()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // Return the exact Cordis disposer so generator effects preserve teardown nesting. return dispose } @@ -243,13 +189,8 @@ export class SubagentService extends Service { } /** - * Start a subagent run on the named provider. Resolves the provider (throws - * `NO_PROVIDER` if absent), validates every requested START-TIME capability - * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` - * for the first unmet one — fail loud, before any child is created), then - * delegates to {@link SubagentProvider.start}, then emits `subagent/start` / - * `subagent/end` only after the run's readiness boundary fulfills. A provider - * that fails before establishing a child emits neither event. + * Start a subagent run on the named provider. + * * @param name - the provider to run on. * @param request - the child's prompt, capabilities, and options. * @returns the live run (its `result` resolves when the child settles). @@ -266,10 +207,7 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) - // Detach every data field before crossing into a provider. Parent/signal - // are live identity capabilities and stay exact; the mutable request record - // and its arrays/objects are never retained, so every backend (including an - // async out-of-process one) observes the request accepted at start. + // Detach every data field before crossing into a provider. const accepted: SubagentStartRequest = { prompt: structuredClone(request.prompt), parent, @@ -282,11 +220,7 @@ export class SubagentService extends Service { } const run = provider.start(accepted) - // Observe result settlement IMMEDIATELY, before waiting on readiness. A - // provider may fail both promises in the same turn; deferring the rejection - // handler until `started` fulfilled would leave `result` transiently - // unhandled. The settled event is buffered until start has been announced, - // preserving start → end order even for an already-settled scripted run. + // Observe result settlement IMMEDIATELY, before waiting on readiness. let readiness: 'pending' | 'started' | 'failed' = 'pending' let pendingEnd: SubagentRunEndInfo | undefined const deliverEnd = (info: SubagentRunEndInfo): void => { @@ -298,10 +232,7 @@ export class SubagentService extends Service { } void run.result.then( (result) => { - // Snapshot before the caller's own `await run.result` continuation. Even - // when readiness is still pending, buffering the clone rather than the - // caller-owned result keeps the eventual observe-only event immutable - // with respect to consumer mutation. + // Snapshot before the caller's own `await run.result` continuation. let lastAssistantMessage: SubagentResult['output'] | undefined try { lastAssistantMessage = structuredClone(result.output) @@ -318,12 +249,7 @@ export class SubagentService extends Service { () => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) }, ) - // Readiness is the publication boundary owned by the provider. For - // in-process runs, fulfillment means the agent registry already contains - // `run.id`; for ACP it means the remote session exists. Emit start with - // per-listener containment, then flush an outcome that settled unusually - // early. A readiness rejection is handled here and deliberately emits no - // false start/end pair; the result path above remains independently handled. + // Readiness is the publication boundary owned by the provider. void run.started.then( () => { readiness = 'started' @@ -343,24 +269,10 @@ export class SubagentService extends Service { } /** - * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch - * each subscriber individually and log (never propagate) a thrown one, so one - * bad subscriber can neither strand the already-live run, surface as an - * unhandled rejection on the detached settle hook, NOR starve the listeners - * registered after it. A single try/catch around `ctx.emit` would not do the - * last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts - * on the first throw — so this resolves the listener callbacks via - * `ctx.events.dispatch` and contains each call, the same guarantee - * `BashExecutor.notifyTaskDone` gives its own listener set. - * - * `subagent/provider-removed` routes through here too: it fires inside the - * provider registration's DISPOSER, where a propagating listener would - * disrupt the backend fiber's teardown (dispose must reach quiescence) and a - * starved later listener would leave a mirror consumer (`dsh-tool-subagent`) - * holding a tool for a provider that no longer exists. `subagent/provider-added` - * deliberately does NOT: it fires at registration time, where a throwing - * listener unwinds the yielded rollback — the same fail-loud register-time - * semantics as the system-prompt registries. + * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch each + * subscriber individually and log (never propagate) a thrown one, so one bad subscriber can + * neither strand the already-live run, surface as an unhandled rejection on the detached + * settle hook, NOR starve the listeners registered after it. */ private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void @@ -370,10 +282,9 @@ export class SubagentService extends Service { info: SubagentRunInfo | SubagentRunEndInfo | string, parent?: Agent, ): void { - // Run lifecycle events dispatch in the DELEGATING PARENT's scope (a - // parent-scoped listener observes only its own delegations); the - // provider-removed registry notification stays unfiltered. The carrier is - // args[0] of the dispatch call, exactly as cordis' own emit spells it. + // Run lifecycle events dispatch in the DELEGATING PARENT's scope (a parent-scoped listener + // observes only its own delegations); the provider-removed registry notification stays + // unfiltered. const dispatchArgs: unknown[] = parent === undefined ? [name, info] : [scopeTarget(this, parent), name, info] diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 003d6a6a3d..1289ac8f7b 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -11,16 +11,10 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' /** - * Which START-TIME features a provider supports. Checked by the service - * BEFORE delegating to {@link SubagentProvider.start}: a request that needs a - * capability the chosen provider lacks is rejected with a typed error rather - * than accepted-then-ignored (the "fail loud, no silent degradation" rule). - * - * Start-time features live here (a static descriptor) because they must be - * checked before a run exists. RUNTIME features (steering, resume) are instead - * modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS - * the capability, and TS narrowing is the discovery mechanism — a consumer - * cannot call an absent method without narrowing first. + * Which START-TIME features a provider supports. Checked by the service before delegating to + * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks + * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent + * degradation" rule). */ export interface SubagentCapabilities { /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 7b9e828c37..71ac7b1a82 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -80,11 +80,10 @@ describe('SubagentService', () => { }) it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => { - // provider-removed fires inside the registration's DISPOSER, so a - // propagating listener would disrupt the backend's teardown; and cordis - // emit halts on the first throw, so an uncontained one would starve every - // mirror registered after it (a stale model-facing tool). Both are - // prevented by per-listener containment. + // provider-removed fires inside the registration's DISPOSER, so a propagating listener + // would disrupt the backend's teardown; and cordis emit halts on the first throw, so an + // uncontained one would starve every mirror registered after it (a stale model-facing + // tool). const ctx = new Context() await ctx.plugin(SubagentService) const warnings: string[] = [] @@ -429,11 +428,8 @@ describe('SubagentService', () => { }) it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => { - // The subagent/end emit fires from a detached `.then` registered before - // start() returns — i.e. BEFORE the caller's own `await run.result` - // continuation. If the event shared the result.output reference, a mutating - // listener would change the SubagentResult the caller consumes. The service - // deep-clones output onto the event, so the listener mutates only its copy. + // The subagent/end emit fires from a detached `.then` registered before start() returns — + // i.e. const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider( @@ -484,11 +480,7 @@ describe('SubagentService', () => { }) it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => { - // The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener - // containment. An uncloneable output (here a content block carrying a - // function) would otherwise throw and become an unhandled rejection on the - // detached `.then`. The handler must instead log and emit the event WITHOUT - // lastAssistantMessage, still carrying the real stopReason. + // The clone runs inside onFulfilled, outside emitLifecycle's per-listener containment. const ctx = new Context() await ctx.plugin(SubagentService) const warn = vi.fn(); ctx.logger.warn = warn as never @@ -552,9 +544,7 @@ describe('SubagentService', () => { const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider('contain')) - // Two listeners; the FIRST throws. Per-listener containment means the second - // must STILL run (a single try/catch around ctx.emit would let the first - // throw halt the dispatch and starve the second — the round-2 regression). + // Two listeners; the FIRST throws. const second = vi.fn() ctx.on('subagent/start', () => { throw new Error('bad start listener') }) ctx.on('subagent/start', second) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 715fb0b9db..d4a7f43514 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -1,26 +1,22 @@ # @deepseek-ai/dsh-tool-subagent -The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees. +Model-facing delegation tool over the [`ctx.subagents`](../subagent/README.md) provider registry. The selected provider may be in-process or out-of-process without changing the model's `{ description, prompt }` request shape. -## Provider selection is config, not model-facing +## Provider binding -This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. +Each plugin load binds one `Config.provider`. To expose multiple providers, load the plugin under distinct `toolName` values. The tool description is derived from `provider.inheritsParentContext`, telling the model whether the child already sees completed parent turns. -## The description states the provider's context contract - -The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). +The tool follows provider availability through `subagent/provider-added` and `subagent/provider-removed`; it has no Loader-order dependency and disappears while its provider is absent. | Config key | Meaning | |---|---| -| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | -| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | -| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. | -| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. | -| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. | -| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. | +| `provider` (required) | Provider name on `ctx.subagents`. | +| `toolName` | Model-facing name (default `subagent`). | +| `agentOptions` | Default child options (`model?`). | +| `persona` | Child persona; requires provider support. | +| `toolFilter` | Child global-tool restriction; requires provider support. | +| `maxDepth` | Delegation-depth cap; requires provider support. | -## Lifecycle (synchronous collect) +## Execution -`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success. - -Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. +`execute` starts a run, bridges the tool abort signal to `run.cancel()`, awaits `run.result`, and always disposes the run. Non-completed stop reasons return error tool results rather than successful partial output. Collection is synchronous; background polling remains deferred in the [subagent seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 4f2a1bc33d..34c818fbf5 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,32 +1,8 @@ /** - * The model-facing `subagent` tool: delegate a task to a child agent and return - * its final output. Pure schema + lifecycle shaping — every transport concern - * lives behind the `ctx.subagents` provider registry - * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend - * swaps in without touching what the model sees. - * - * Provider selection is config, not model-facing: this plugin is bound to - * EXACTLY ONE provider name (`Config.provider`). To expose more than one - * transport, load the plugin more than once, each bound to a different provider - * — there is no provider/type parameter in the model-facing schema. The model - * sees only `{ description, prompt }`. - * - * The tool DESCRIPTION is derived from the bound provider's context contract - * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the - * standalone-prompt wording, an inheriting provider (fork) tells the model the - * child already sees the conversation's completed turns. The tool MIRRORS the - * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers - * when the provider is (or becomes) available and unregisters when the - * provider goes away — so no load-order requirement exists and an HMR reload - * of the backend re-derives the wording from the fresh provider. - * - * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits - * `run.result` inside a `try/finally` that always disposes the run, so the - * owned child agent/session is torn down on every path (success, error, abort) - * and never leaks as a live idle child. A non-`completed` stop reason maps to an - * `isError` tool result (by throwing) rather than returning partial output as - * success. - * + * The model-facing `subagent` tool: delegate a task to a child agent and return its final + * output. Pure schema + lifecycle shaping — every transport concern lives behind the + * `ctx.subagents` provider registry (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or + * future A2A backend swaps in without touching what the model sees. * @module @deepseek-ai/dsh-tool-subagent */ @@ -101,16 +77,8 @@ export const Config: z = z.object({ model: z.string(), }).default(undefined as unknown as { model: string }), persona: z.string(), - // A schemastery object materializes {} (with [] for nested arrays) when the - // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. - // deny-everything, silently. Force the omitted key to stay absent (the same - // shape discipline as SystemPrompt's toolOrder); the cast is needed because - // .default() expects the object type. - // The NESTED arrays get the same treatment as the object itself: a partial - // filter ({deny: […]}) must not materialize allow: [] beside it — an empty - // allow-list means deny-EVERYTHING, so the materialized default would turn - // a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only - // children) survives, since only the omitted key defaults to undefined. + // A schemastery object materializes {} (with [] for nested arrays) when the key is omitted — + // for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. deny-everything, silently. toolFilter: z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), @@ -194,14 +162,10 @@ export function apply(ctx: Context, config: Config): void { if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') } - // The tool MIRRORS its provider's lifecycle instead of assuming load order: - // the cordis Loader starts sibling entries concurrently, so "backend listed - // first in cordis.yml" does not guarantee "provider registered first", and - // an HMR reload of the backend replaces the provider while this fiber stays - // loaded. Register the tool when the bound provider is (or becomes) - // available — deriving the wording from THAT provider — and unregister it - // when the provider goes away, so the description can never outlive or - // predate the provider it describes. + // The tool MIRRORS its provider's lifecycle instead of assuming load order: the cordis Loader + // starts sibling entries concurrently, so "backend listed first in cordis.yml" does not + // guarantee "provider registered first", and an HMR reload of the backend replaces the + // provider while this fiber stays loaded. let disposeTool: (() => Promise | void) | undefined const mount = (provider: SubagentProvider): void => { const wording = providerWording(provider.inheritsParentContext) @@ -245,10 +209,8 @@ export function apply(ctx: Context, config: Config): void { // aborted while the child is in flight, cancel the child too. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before this - // line, so a step cancelled before the tool ran would never reach the - // child. Cancel explicitly in that case — the bridge must honor an - // already-aborted signal, not lean on each provider re-checking it. + // `addEventListener` does not fire for a signal already aborted before this line, so a + // step cancelled before the tool ran would never reach the child. if (exec.signal?.aborted) run.cancel('parent step aborted') try { @@ -269,16 +231,8 @@ export function apply(ctx: Context, config: Config): void { })) } - // Listeners first, then the presence check: both run synchronously, so no - // registration can slip between them; the `disposeTool === undefined` guard - // makes a same-tick added-event after a successful mount a no-op. - // TODO(subagent-dup-toolname): two WAITING fibers configured with the same - // toolName collide only when their provider finally arrives — the duplicate - // tool-name throw then propagates through `subagent/provider-added` and - // rolls back the PROVIDER registration, so an invalid config blasts the - // backend's fiber instead of the misconfigured tool's. Config-time detection - // would need a cross-fiber registry of intended tool names; revisit if a - // real deployment ever hits it. + // Register listeners before the synchronous presence check to avoid an activation gap. + // TODO(subagent-dup-toolname): validate intended tool names before provider activation. ctx.on('subagent/provider-added', (provider) => { if (provider.name === config.provider && disposeTool === undefined) mount(provider) }) @@ -292,8 +246,6 @@ export function apply(ctx: Context, config: Config): void { mount(present) } else { // Not an error: the backend's fiber may simply activate after this one. - // The tool appears the moment the provider registers; a typo'd provider - // name shows up as this note plus a tool that never materializes. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) } } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 95d26dabdf..cafc889bdc 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -370,12 +370,9 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) - // Abort AFTER the tool body has had a chance to register its abort listener - // (ctx.tools.execute now awaits the tools/pre-execute waterfall before the - // body runs, so the listener is not registered synchronously). A few - // microtask turns let execute() reach `addEventListener('abort')`, so this - // exercises the LIVE onAbort bridge — distinct from the already-aborted - // sync path the next test covers. + // Abort after the tool body has had a chance to register its abort listener + // (ctx.tools.execute now awaits the tools/pre-execute waterfall before the body runs, so + // the listener is not registered synchronously). await Promise.resolve() await Promise.resolve() controller.abort() @@ -385,11 +382,9 @@ describe('dsh-tool-subagent', () => { }) it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => { - // `addEventListener('abort')` does not fire for a signal already aborted - // before the listener is added, so a step cancelled before the tool ran - // would never reach the child unless the bridge re-checks `signal.aborted`. - // A provider that leans only on the abort EVENT (this spy never inspects - // request.signal) proves the bridge itself must cancel. + // `addEventListener('abort')` does not fire for a signal already aborted before the + // listener is added, so a step cancelled before the tool ran would never reach the child + // unless the bridge re-checks `signal.aborted`. const cancelled = vi.fn() const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -442,10 +437,7 @@ describe('dsh-tool-subagent', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so - // a stray `export default apply` would collapse the module via - // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at - // load with "cannot get property … without inject". Guard the shape directly. + // Loader must retain this namespace's injection metadata. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent') expect(tool.inject).toEqual(['tools', 'subagents']) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 491d3ea884..e5f7571fa3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -1,18 +1,7 @@ /** - * Shared subprocess harness for ACP snapshot suites. A library module driven by - * the suite factory in ./suite.ts (and directly by harness-level specs); each - * example's `*.snapshot.ts` names its own agent-under-test paths. - * - * It boots the REAL agent bin subprocess via the cordis Loader (so the - * export-shape bug class stays guarded — see docs/postmortem/0001), drives it - * over real ACP JSON-RPC stdio with a deterministic input script, tees raw - * stdout (for the golden + a purity check) into an SDK `ClientSideConnection`, - * and — in record mode — harvests the persisted session JSONL after a graceful - * shutdown flush. The pure normalizers in ./normalize.ts turn the captured - * stdout frames and the session-log events into stable, snapshot-able text. - * - * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. - * + * Shared subprocess harness for ACP snapshot suites. A library module driven by the suite + * factory in ./suite.ts (and directly by harness-level specs); each example's `*.snapshot.ts` + * names its own agent-under-test paths. * @module @deepseek-ai/dsh-acp-snapshot/harness */ @@ -66,16 +55,10 @@ export interface AgentUnderTest { } /** - * One step of a scenario's deterministic input script (`input.json`). The - * harness interprets these in order. `newSession` captures the server-issued - * (random) session id into a `{{sessionId}}` variable that later steps - * reference, since a committed file cannot know the id in advance. - * - * `promptAndCancel` sends a prompt WITHOUT awaiting its response, waits until - * the client observes the first streamed `agent_message_chunk` (so the emitted - * frames deterministically precede the cancellation), then cancels the turn — - * the only way to exercise a cancel deterministically (a plain `prompt` step - * awaits the response, which a cancel/hang scenario would block on forever). + * One step of a scenario's deterministic input script (`input.json`). The harness interprets + * these in order. `newSession` captures the server-issued (random) session id into a + * `{{sessionId}}` variable that later steps reference, since a committed file cannot know the + * id in advance. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -92,16 +75,8 @@ export type InputStep = export interface InputScript { steps: InputStep[] /** - * Ordered answers for the agent's `session/request_permission` round-trips, - * consumed FIFO — the Nth request gets the Nth answer. Each answer selects - * by option KIND: option ids are agent-issued randoms a committed script - * cannot know, while kinds are the ACP-stable vocabulary, so the client maps - * kind → the offered `optionId` at answer time. A request beyond the queue - * (or with no queue at all) is answered `cancelled` — the stub behavior a - * scenario without approvals relies on. A scripted kind the request does - * not offer REJECTS the run: the scenario scripted an impossible click, - * and {@link runScenario} throws once the in-flight step settles (the - * agent itself just sees `cancelled`, so it cannot absorb the bug). + * Ordered answers for the agent's `session/request_permission` round-trips, consumed FIFO — + * the Nth request gets the Nth answer. */ permissionAnswers?: PermissionAnswer[] } @@ -201,8 +176,6 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const stderrChunks: string[] = [] try { // Seed the workspace if the scenario ships one (a file the agent reads/edits). - // Copied into the temp cwd so the agent's bash tools see it; the goldens - // normalize the cwd, so the seeded paths stay stable across runs. if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } @@ -229,10 +202,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child.stderr.setEncoding('utf8') child.stderr.on('data', (c: string) => stderrChunks.push(c)) - // Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO - // feed the same bytes to the SDK client through a passthrough. Buffer the raw - // bytes (not per-chunk utf8 strings) and decode once at the end, so a - // multibyte sequence split across two 'data' events can't corrupt the golden. + // Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO feed the + // same bytes to the SDK client through a passthrough. const passthrough = new Readable({ read() {} }) child.stdout.on('data', (buf: Buffer) => { rawBuffers.push(buf) @@ -255,13 +226,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] - // A scenario bug detected inside a client callback (a scripted permission - // kind the agent never offered). It cannot fail the run from in there: a - // callback throw only becomes a JSON-RPC error RESPONSE to the agent, and - // a tolerant agent treats that as a denial and carries on — the run (or - // worse, a record) would absorb the impossible click silently. So the - // callback answers `cancelled` (a well-defined path for the agent), - // captures the error here, and the step loop fails the run on it. + // A scenario bug detected inside a client callback (a scripted permission kind the agent + // never offered). let scriptError: Error | undefined const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { @@ -356,10 +322,8 @@ async function runStep( return } case 'newSessionExpectError': { - // The bridge rejects a session/new that widens the workspace scope - // (non-empty additionalDirectories / mcpServers — unimplemented). The SDK - // surfaces that as a rejected RPC; swallow it so the run completes and the - // error frame is captured in the transcript. + // The bridge rejects a session/new that widens the workspace scope (non-empty + // additionalDirectories / mcpServers — unimplemented). await client.newSession({ cwd, mcpServers: [], @@ -379,10 +343,8 @@ async function runStep( case 'promptExpectError': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession') - // The model fails this turn (a recorded provider error), so the bridge - // answers the prompt with a JSON-RPC error and the SDK rejects. That - // rejection IS the expected editor experience — swallow it so the run - // completes and the stdout transcript (the error frame) is captured. + // The model fails this turn (a recorded provider error), so the bridge answers the prompt + // with a JSON-RPC error and the SDK rejects. await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) .then(() => { throw new Error('snapshot-harness: expected the prompt to fail but it succeeded') }, () => { /* expected: the turn failed and the bridge returned an error */ }) @@ -391,13 +353,7 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on - // its own). To pin frame order deterministically, wait until the client - // has OBSERVED the hang's streamed agent_message_chunk before cancelling — - // so those update frames always precede the cancelled prompt response in - // the transcript (without this, the late chunk and the response race). - // Then cancel and await the prompt, which the bridge settles as - // `cancelled` once the abort propagates. + // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on its own). const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk') await client.cancel({ sessionId }) @@ -483,14 +439,7 @@ async function harvestSessionLogs(root: string): Promise { }) } } - // Primary (no parentSession) first, then children by ascending createdAt. A - // scenario has exactly one top-level session. In the synchronous cut sibling - // children are created strictly sequentially, so their createdAt values are - // strictly ordered; the recordedId tiebreak only keeps a degenerate - // same-millisecond collision (unreachable here) deterministic. This harvest - // order must match the replay load order in dsh-llm-replay's loadSessionScripts - // so session..jsonl maps to the same child on record and replay — replay - // re-sorts childFiles by the same key, so the two stay consistent. + // Primary (no parentSession) first, then children by ascending createdAt. logs.sort((a, b) => { const ap = a.parentSession === undefined ? 0 : 1 const bp = b.parentSession === undefined ? 0 : 1 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 74bee95385..43fe75b9cb 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,17 +1,6 @@ /** - * ACP snapshot suite kit — the shared machinery behind the keyless snapshot - * tier (`pnpm run test:snapshot`). Three layers, composable per example: - * the subprocess scenario harness ({@link runScenario}), the pure golden - * normalizers ({@link normalizeStdout} / {@link normalizeSessionLog} / - * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite factory - * ({@link defineAcpSnapshotSuite}) that registers a scenario table as a full - * describe/it tree. An example's `*.snapshot.ts` supplies only its - * {@link AgentUnderTest} paths, its snapshots directory, and its - * {@link Scenario} table. - * - * NOTE: ./suite.ts imports vitest, so this package is importable only inside a - * vitest run — a support-tier constraint stated in the README. - * + * ACP snapshot suite kit — the shared machinery behind the keyless snapshot tier (`pnpm run + * test:snapshot`). * @module @deepseek-ai/dsh-acp-snapshot */ diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index a68fcf83d3..15e542e0f0 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -1,29 +1,8 @@ /** - * Pure normalizers for the ACP snapshot goldens. They replace the - * non-deterministic values in the two captured surfaces — the stdout JSON-RPC - * transcript and the persisted session JSONL — with stable tokens, so a golden - * compare reflects behavior, not run-to-run noise. Kept dependency-free and - * side-effect-free so they unit-test trivially. - * - * Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp` - * cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header); - * JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event - * `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's - * `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq` - * (deterministic — `seq = log.length`, part of the event-log contract). - * - * Separate, composable normalizers keep bulky request-header content out of - * session fixtures. {@link scrubSystemPrompts} replaces the composed system - * prompt in EVERY fixture; {@link scrubRequestHeaders} additionally replaces - * tool schemas and the session prefix outside each suite's header-pinning - * scenario. They are deliberately NOT folded into - * {@link normalizeSessionLog}: the suite factory composes the right scrub for - * each scenario and snapshots the pin's actual prompt as Markdown (see the - * pinned-header RFC, - * docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). - * - * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. - * + * Pure normalizers for the ACP snapshot goldens. They replace the non-deterministic values in + * the two captured surfaces — the stdout JSON-RPC transcript and the persisted session JSONL — + * with stable tokens, so a golden compare reflects behavior, not run-to-run noise. Kept + * dependency-free and side-effect-free so they unit-test trivially. * @module @deepseek-ai/dsh-acp-snapshot/normalize */ @@ -68,12 +47,9 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown { } /** - * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a - * stable golden in the SAME shape as the wire: one compact JSON frame per line - * (NDJSON), with the JSON-RPC `id` rewritten to a per-transcript sequence - * (1, 2, 3, …) and all volatile strings scrubbed. Throws if any non-empty line - * is not valid JSON — that doubles as the stdout-purity check (no logger leaked - * onto the protocol). + * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable golden + * in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC + * `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed. * * @param rawStdout The captured stdout bytes, decoded utf8. * @param ctx The run's volatile values to scrub. diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 418470f2be..e7936dd407 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -1,33 +1,5 @@ /** - * The ACP snapshot suite factory (REPLAY by default, keyless). A suite is a - * scenario table plus a snapshots directory: each scenario under - * `//` ships an `input.json` (the client stdin script) and - * a `session.jsonl` fixture; replay boots the real agent subprocess - * (./harness.ts), drives it, and diffs the normalized stdout transcript - * against the committed `stdout.golden.jsonl`. For model scenarios it ALSO - * checks the re-persisted session log — against the `session.jsonl` fixture - * itself, not a separate golden: the fixture doubles as the replay source - * (recorded scenarios) and the expected produced log (both sides normalized - * before comparing). - * - * Request-header content is pinned by exactly ONE scenario per HEADER CLASS — - * scenarios that boot the same config compose the same header. Every JSONL - * fixture scrubs the system prompt to `{{system}}`; each class's pinning - * scenario stores the readable prompt in `system-prompt.golden.md` and keeps its full - * tool schemas in `session.jsonl`, while every other fixture also scrubs tools - * to `{{tools}}`. A per-run uniformity guard compares both artifacts against - * every live header and forbids unrepresented header deltas (see the - * pinned-header RFC, - * docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). - * - * `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the - * `session.jsonl` fixtures against the real API and refreshes the stdout golden - * in one pass. `pnpm run test:snapshot:refresh` (DSH_SNAPSHOT=refresh) instead - * replays the committed model scripts keylessly and writes the current stdout - * + persisted-log goldens back without calling a live LLM. The caller resolves - * that env into {@link SnapshotSuiteOptions} (env reading stays at the suite - * edge, not in this library). - * + * The ACP snapshot suite factory (replay by default, keyless). * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -89,20 +61,7 @@ export interface Scenario { */ childSessions?: number /** - * Whether THIS scenario pins its header class's model-facing request-header - * content. Its actual composed prompt is maintained as a readable - * `system-prompt.golden.md`; its JSONL keeps full tool schemas but stores the prompt - * as `{{system}}`. Every other scenario of the class stores tools as - * `{{tools}}` too ({@link scrubRequestHeaders}). A prompt or tool-schema - * change therefore shows up in one focused artifact per class, not every - * session fixture. One pin per class suffices because - * header composition is class-uniform (parent, spawn child, and fork child - * all compose the same prompt-modulo-cwd and the same tools) — and that - * premise is ASSERTED, not assumed: every non-pinning run's live headers - * must equal its class's pinned fixture's (normalized), so a - * session-dependent header (say, a restricted subagent toolset) fails loud - * until it gets its own pinning scenario. - * Defaults to false. + * Whether this scenario pins its header class's model-facing request-header content. */ pinsHeader?: boolean /** @@ -164,17 +123,8 @@ export function childFixturePaths(dir: string, childSessions: number): string[] } /** - * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own - * header line (`{ type: 'session', id, cwd }`). A committed fixture carries the - * session id and cwd of the run that harvested it — different from the live - * replay run — so normalizing it against the live run's ctx would leave those - * recorded values unscrubbed. Reading them from the header scrubs the fixture's - * own id/cwd to the same `{{sessionId}}`/`{{cwd}}` tokens the replay output gets. - * An authored fixture whose header is already normalized (`id:'{{sessionId}}'`, - * `cwd:'{{cwd}}'`) yields those tokens as the volatile values, so scrubbing them - * is an idempotent no-op. A header with no `cwd` falls back to a sentinel that - * cannot occur in a log (NOT `''`, which `String.split` would match on every - * character boundary and corrupt the output). + * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own header line + * (`{ type: 'session', id, cwd }`). * * @param fixture The committed `session.jsonl` content. * @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}. @@ -419,10 +369,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { for (const scenario of scenarios) { describe(`snapshot: ${scenario.name}`, () => { - // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the - // `authored` ones (sidecar-driven errors/cancel) are never re-recorded. - // REFRESH mode is replay-backed and deterministic, so it runs every - // scenario and rewrites the comparable fixtures from that replay run. + // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones + // (sidecar-driven errors/cancel) are never re-recorded. it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript @@ -444,10 +392,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, }) - // Scrub every volatile id the run produced: the ACP server-issued session - // id plus every harvested log's recorded id (a subagent child id never - // surfaces over ACP, but it appears in the child's own log header). The - // normalizer's UUID catch-all covers any we don't enumerate. + // Scrub every volatile id the run produced: the ACP server-issued session id plus every + // harvested log's recorded id (a subagent child id never surfaces over ACP, but it + // appears in the child's own log header). const ctx: NormalizeContext = { sessionIds: [ ...result.sessionId !== undefined ? [result.sessionId] : [], @@ -456,15 +403,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { cwd: result.cwd, } - // RECORD mode (recorded model scenarios only): persist the freshly-harvested - // live logs back to their fixtures. REFRESH mode does the same from a - // keyless replay run for every comparable log, including authored - // scenarios that live record deliberately skips. The primary goes to - // session.jsonl, each child to session..jsonl in harvest order. A - // Every fixture is written with its system prompt scrubbed. A pinning - // scenario keeps the remaining header content (notably tool schemas); - // every other scenario scrubs that bulk too. Record/refresh therefore - // cannot smuggle prompt text back into JSONL or duplicate schemas. + // RECORD mode (recorded model scenarios only): persist the freshly-harvested live logs + // back to their fixtures. const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders @@ -515,14 +455,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // A model turn always produces a log worth comparing; a hook scenario can // produce one without a model turn (a `rejected` turn carrying `hook/*`). if (comparesLog) { - // The harvested logs (primary-first) must match their committed fixtures - // 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS - // OWN volatile values — the live run's via `ctx`, the committed fixture's - // via its own header (a committed file cannot share the live run's ids). - // Both sides pass through the scenario's idempotent scrub: every live - // prompt becomes the fixture's `{{system}}`; non-pinning scenarios - // additionally tokenize tools/prefix. The dedicated header guard below - // compares those omitted values against their class's pin artifacts. + // The harvested logs (primary-first) must match their committed fixtures 1:1. expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) @@ -532,11 +465,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } - // Header-uniformity guard: every live header in a class must equal the - // class pin split across its JSONL header (system token + real tools) - // and readable Markdown prompt. A pinning scenario may carry its - // declared header deltas; their prompt edits live in the Markdown - // golden while JSONL retains the tokenized edit structure. + // Header-uniformity guard: every live header in a class must equal the class pin split + // across its JSONL header (system token + real tools) and readable Markdown prompt. /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */ const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario const pinningDir = join(snapshotsDir, pinningScenario.name) @@ -586,18 +516,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { }) it('every registered scenario has its required fixture files', () => { - // Every scenario has an input script and an stdout golden. EVERY scenario - // also needs `session.jsonl`: the suite boots `llm-replay` with that path - // as the replay source for ALL scenarios (the factory passes - // `fixtureFile: /session.jsonl` unconditionally), and `loadReplayScript` - // throws "fixture not found" when it is absent and no override replaces it. - // A no-model scenario ships a header-only `session.jsonl` (it derives to an - // empty script — no model call is made); a model scenario's fixture also - // doubles as the expected-log artifact the run is diffed against. The - // `replay.override.json` sidecar is matched BOTH ways against the table's - // `overridden` flag: required when set, forbidden when not — the harness - // forwards the file purely on existence, so an unregistered stray sidecar - // would silently replace the derived script. + // Every scenario has an input script and an stdout golden. for (const { name, overridden, childSessions, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) @@ -616,10 +535,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { }) it('exactly one scenario pins the request-header content of each header class', () => { - // Zero pins would drop a class's prompt/schema surface from the suite - // entirely; two would split it. One pin per class is the design - // (pinned-header RFC); WHICH scenario pins is the scenario table's - // reviewable choice. + // Zero pins would drop a class's prompt/schema surface from the suite entirely; two would + // split it. const pins = new Map() for (const scenario of scenarios.filter(s => s.pinsHeader === true)) { const cls = classOf(scenario) @@ -633,12 +550,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { }) it('every pinning fixture carries one request/header, one readable prompt, and its declared deltas', async () => { - // The live uniformity guard runs only in NON-pinning scenarios, so a - // class made of just its pinning scenario would otherwise accept a - // re-recorded pin with several headers or an undeclared mid-run - // header-delta — shapes the pin design cannot represent. Assert the - // committed pins directly; a scenario whose arc legitimately rewrites - // a prompt section declares the exact count via expectedHeaderDeltas. + // The live uniformity guard runs only in NON-pinning scenarios, so a class made of just + // its pinning scenario would otherwise accept a re-recorded pin with several headers or + // an undeclared mid-run header-delta — shapes the pin design cannot represent. for (const scenario of pinningByClass.values()) { const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8') const headers = normalizedHeaders(fixture, fixtureContext(fixture)) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 861c9d2b04..553843df3d 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -1,19 +1,5 @@ /** - * Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. Speaks - * newline-delimited JSON-RPC on stdio like the real `dsh-acp-agent` bin, but - * every behavior — how prompts settle, whether session/new rejects, which - * session logs get persisted, what filesystem noise to leave — comes from a - * `behavior.json` sitting NEXT to the `$DSH_SNAPSHOT_FILE` fixture, so a spec - * scripts a whole subprocess run from data. The specs launch it through the - * REAL `runScenario` spawn path (tsx loader, temp cwd, env plumbing), so the - * harness plumbing is exercised for real; only the agent behind the protocol - * is scripted. - * - * The specs (not the golden tier) own this bin: it asserts nothing, echoes - * observable facts into `session/update` text chunks (env probe, permission - * outcome, seeded-workspace listing) for the spec to read off `rawStdout`, and - * exits 0 on stdin EOF after writing the scripted logs — mirroring the real - * bin's dispose-flush-exit shape. + * Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. */ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b0817a8d04..b5da987a3c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -308,11 +308,7 @@ describe('runScenario', () => { it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true }) - // The fake bin offers allow_once/reject_once; scripting allow_always is a - // scenario bug. The agent is answered `cancelled` (it must not be able to - // absorb the bug as an error-means-denial), and the RUN fails: a callback - // throw would only reach the agent as a JSON-RPC error response, letting - // a tolerant agent carry on and the scenario pass — or record. + // The fake bin offers allow_once/reject_once; scripting allow_always is a scenario bug. await expect(runScenario( { steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] }, { agent: AGENT, mode: 'replay', fixtureFile }, diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index d6e3e2b912..0d8b25a5ba 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -18,21 +18,11 @@ import { } from '../src/suite.ts' /** - * Unit tests for the suite factory, by running it: two synthetic suites over - * the scripted fake ACP bin (./fixtures/fake-acp-agent.ts) register REAL - * describe/it trees at collection time, so every factory path — golden and log - * compares, the per-suite header pin and its uniformity guard, record-mode - * fixture write-back, skip semantics, and the fixture guard block — executes - * as an ordinary green test. The pure helpers get direct cases below. - * - * The replay suite runs against the committed fixtures in ./fixtures/suite. - * The record suite runs against a TEMP COPY of ./fixtures/record-suite - * (record mode writes session fixtures back into its snapshots dir; a run must - * never touch the committed tree). To re-bootstrap the record tree's goldens - * after changing the fake bin's output, run this spec once with - * `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` (points the record suite at the committed - * tree so vitest creates/updates the goldens and the write-back lands there), - * then commit the result. + * Unit tests for the suite factory, by running it: two synthetic suites over the scripted fake + * ACP bin (./fixtures/fake-acp-agent.ts) register real describe/it trees at collection time, + * so every factory path — golden and log compares, the per-suite header pin and its uniformity + * guard, record-mode fixture write-back, skip semantics, and the fixture guard block — + * executes as an ordinary green test. */ const AGENT = { @@ -44,12 +34,7 @@ const AGENT = { const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url)) const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url)) -// The replay suite doubles as the header-CLASS coverage: every scenario names -// the same explicit class (the record suite exercises the 'default' fallback), -// and plain-turn boots through a per-scenario configPath override (the same -// dummy path the agent default carries — the plumbing, not the composition, -// is what this suite can exercise; the real overlay boot is the acp-agent -// example's code-mode scenarios). +// Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'main' }, { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index ee431a7f39..237a242f42 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,21 +1,7 @@ /** - * Dev-mode invariants: a pure-listener plugin that asserts the harness event - * contract at runtime, and (optionally) freezes logged session-event data so - * any code that mutates history throws instead of corrupting silently. - * - * Everything is a plugin — this is just listeners on `session/created`, - * `session/event`, and `agent/status`. It is **off in production**: enable it - * in tests and the demos, where a contract violation should be a loud failure, - * not a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below ARE the contract. - * - * Why runtime assertions instead of compile-time deep-readonly types? See - * the dev-invariants RFC. Briefly: a `DeepReadonly` is high type-noise across - * every log consumer and a plugin casts straight through it; a dev-mode freeze - * + assertions catch real corruption at zero production cost and zero type - * noise. The always-on half of that defense (cloning derived messages) lives - * in dsh-session; this package is the dev-mode tripwire. - * + * Dev-mode invariants: a pure-listener plugin that asserts the harness event contract at + * runtime, and (optionally) freezes logged session-event data so any code that mutates history + * throws instead of corrupting silently. * @module @deepseek-ai/dsh-invariants */ @@ -133,10 +119,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { // surface-eligible event types. The compiler enforces this at append() // call sites; this runtime check catches casts and persisted data. const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message']) - // Cast to surface-eligible event type so we can access surfaceOp and - // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). - // SurfaceEvent's mandatory surfaceOp is too strict here — we need to - // CHECK whether surface metadata is present, not assume it. + // Cast to surface-eligible event type so we can access surfaceOp and sourceEventSeqs + // (optional on SessionEvent, mandatory on SurfaceEvent). const se = event as SessionEvent if (!SURFACE_TYPES.has(event.type)) { if (se.sourceEventSeqs !== undefined) { @@ -196,10 +180,9 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { } } - // Boundary/step-scoped events have explicit cases; every OTHER event type — - // including plugin-added (merge-extensible) SessionEventMap keys — is caught - // by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an - // unknown variant is valid, not a compile error. + // Boundary/step-scoped events have explicit cases; every OTHER event type — including + // plugin-added (merge-extensible) SessionEventMap keys — is caught by the `default` and must + // be turn-enclosed (the turn-enclosure RFC). switch (event.type) { case 'turn/start': { if (trace.openTurn !== null) { @@ -264,25 +247,15 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { } case 'tool/result': { requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) - // A result needs a prior matching call in the same step. (The converse - // does NOT hold: a call may have no result — a throwing tool-execution - // pipeline step ends the turn with no tool/result, which is legal.) + // A result needs a prior matching call in the same step. const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) { throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } break } - // Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary - // case above must sit inside an open turn. The durable session log uses the - // turn as its commit/replay boundary (the JSONL backend treats anything - // after the last turn/end as a crash tail), so a bare event between turns is - // silently dropped on reload. The loop records queued user messages after - // turn/start, and an idle agent.inject() wraps its context/message in a - // one-shot turn. A `default` - // (not an enumerated list) is deliberate: SessionEventMap is - // merge-extensible, so a PLUGIN-added event type appended while idle must - // also fail here rather than fall through and be dropped on resume. + // Turn-enclosure (the turn-enclosure RFC): every session event not handled by a boundary + // case above must sit inside an open turn. default: { if (trace.openTurn === null) { throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) @@ -370,19 +343,7 @@ export function apply(ctx: Context, config: Config = {}): void { lastStatus.set(agent, status) }) - // --- Scoped-dispatch invariants (the agent-scoping seam) --------------- - // - // Every scope-filtered event family must dispatch with a scope carrier - // (scopeTarget) whose key IS the subject the event's arguments name — - // a dispatch without one silently reverts that event to global delivery - // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one - // delivers to the wrong agent's listeners. `internal/dispatch` fires - // synchronously before listener delivery, so a violation throws at the - // dispatching call site. The table maps each family to how its subject is - // read from the event arguments; `null` = the subject is not recoverable - // from the arguments (session events key by the OWNING agent; subagent - // lifecycle events key by the delegating parent), so only carrier - // PRESENCE is asserted there. + // Scope-filtered events must carry a scopeTarget keyed to their subject. const scopedSubject: Record unknown) | null> = { 'agent/created': args => args[0], 'agent/disposed': args => args[0], @@ -435,20 +396,8 @@ export function apply(ctx: Context, config: Config = {}): void { } }, { global: true }) - // --- Setup-drives invariant --------------------------------------------- - // - // CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not - // DRIVE the agent. ReactLoopAgent rejects every driving verb structurally - // until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the - // cross-implementation backstop for alternate Agent implementations and raw - // session writes. A turn/start appended before agent/session-start is a - // creation-time misuse, reported at the appending call site. Sessions of - // agents that exist BEFORE this plugin applies are marked started (their - // ordering is unknowable after the fact — never a false positive on HMR). - // `agents` is read via ctx.get (a strict, optional store lookup) rather - // than injected: the invariants plugin must load in harnesses that carry - // no agent registry at all (bare session tests), where this check simply - // never trips. + // --- Setup-drives invariant CreateAgentOptions.setup COMPOSES the agent's scoped world; it + // must not DRIVE the agent. const sessionStarted = new WeakSet() for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session) ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) }) @@ -462,31 +411,7 @@ export function apply(ctx: Context, config: Config = {}): void { + '(send/steer/inject belong after creation returns)') }) - // Request-reconstruction cross-check (the reconstructability RFC): a - // loop-built request — frozen envelope + live sessionId is the marker; a - // hand-built one-shot (compaction summarize) is unfrozen and skipped — must - // be EXACTLY what the session log reconstructs: - // - // - messages: the folded header's session prefix (messagePrefix — the - // `agent/session-prefix` product, logged on the header because no - // session event carries it) followed by the - // derivation over the log prefix strictly before the in-flight step's - // `step/start` (the reconstruction boundary). The derivation is compared - // against a FRESH Session built over that prefix — the same projection - // code with zero shared state, so the live cache under test cannot vouch - // for itself. Boundary-correct by construction: content appended after - // the boundary (an `agent/request`-window inject) is legitimately absent - // from this request, and a current-surface comparison would false-fire. - // - header: every non-content field must equal the fold of the log's - // `request/header*` events — the loop logs the header event BEFORE - // dispatch, so the fold already covers this request. - // - // Registered with `prepend: true` so a short-circuiting llm/stream listener - // (the replay adapter returns its chunks without calling next()) cannot - // silence the check by registering first. Prepend beats APPEND-registered - // listeners only — two prepended listeners have no defined mutual order - // (cordis unshift) — which is fine: correctness rests on the seq-bounded - // fold below, never on listener timing. + // Frozen loop requests must equal reconstruction from the header and pre-step log prefix. ctx.on('llm/stream', (options: GenerateOptions, next) => { if (options.sessionId === undefined || !Object.isFrozen(options)) return next() // GenerateOptions types sessionId as Branded<'SessionId'>, which IS @@ -498,9 +423,7 @@ export function apply(ctx: Context, config: Config = {}): void { } const events = session.events - // seq === index (checked above), so the last step/start's seq bounds the - // prefix directly. The in-flight step's step/start is necessarily the - // last one: the loop cannot open another step while this call streams. + // seq === index (checked above), so the last step/start's seq bounds the prefix directly. let boundary = -1 for (let i = events.length - 1; i >= 0; i -= 1) { if (events[i]?.type === 'step/start') { @@ -516,12 +439,9 @@ export function apply(ctx: Context, config: Config = {}): void { throw new InvariantError('a loop-built request with no request/header event in its session log') } const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) - // The reconstruction equation: the folded header's session prefix, then - // the boundary derivation — the loop - // logs the header event BEFORE dispatch, so the fold already covers this - // request's prefix. JSON equality is sound here: both sides are - // structuredClones produced by the same projection/build code path, so key - // insertion order matches when the values do. + // The reconstruction equation: the folded header's session prefix, then the boundary + // derivation — the loop logs the header event before dispatch, so the fold already covers + // this request's prefix. const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index ad3792b302..6d39c44ad9 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -317,12 +317,7 @@ describe('dev-freeze', () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // A caller hands in a SHALLOW-frozen block whose nested array is still - // mutable. deepFreeze must descend into the already-frozen object and - // freeze the descendant, not short-circuit on the frozen container — - // otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches. - // `append` snapshots `data`, so the freeze applies to the LOGGED clone, not - // the caller's input — read the event back and assert on its data. + // deepFreeze must traverse a shallow-frozen event clone and freeze its nested data. const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -335,12 +330,8 @@ describe('dev-freeze', () => { it('terminates on a cyclic event datum (WeakSet guard)', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - // The deep-freeze WeakSet guard must terminate on a self-referential - // structure rather than recursing forever. Session.append now rejects - // non-serializable (incl. cyclic) data at the source, so drive the freeze - // handler directly via hand-built session/events — exactly the shape the - // invariants listener receives. Open a turn first (seq 0) so the cyclic - // user/message (seq 1) satisfies the turn-enclosure invariant. + // The deep-freeze WeakSet guard must terminate on a self-referential structure rather than + // recursing forever. ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) const cyclic: Record = { type: 'text', text: 'x' } cyclic['self'] = cyclic @@ -507,9 +498,8 @@ describe('surface invariants', () => { }) it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { - // The unknown-seq check fires when a ref passes the "earlier" test but is - // not in knownSeqs — only possible with a gap in seqs. We create a gap by - // directly manipulating the private log array to skip a seq. + // The unknown-seq check fires when a ref passes the "earlier" test but is not in knownSeqs + // — only possible with a gap in seqs. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -523,9 +513,7 @@ describe('surface invariants', () => { time: Date.now(), data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }, }) - // Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes - // is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not - // in knownSeqs ({0, 1, 3} — gap at 2). + // Now the log has seqs 0, 1, 3 (gap at 2). expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) }).toThrow(/unknown seq 2/) @@ -617,10 +605,8 @@ describe('surface invariants', () => { session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the - // head seq (4) is numerically GREATER than the tail seq (3): the surface is - // not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is - // valid positionally and must be accepted even though start seq > end seq. + // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the head seq (4) is + // numerically GREATER than the tail seq (3): the surface is not seq-ordered. session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5 @@ -769,12 +755,9 @@ describe('request-reconstruction cross-check (llm/stream)', () => { describe('request cross-check ordering (prepend)', () => { it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => { - // The replay adapter returns its chunks WITHOUT calling next(), which - // would silence a later-registered check — snapshot compositions load - // replay before the app bundle that loads invariants. The check prepends, - // so it fires ahead of append-registered listeners regardless of load - // order. (Prepend orders it against APPENDED listeners only; correctness - // rests on the seq-bounded rebuild, not on listener timing.) + // The replay adapter returns its chunks WITHOUT calling next(), which would silence a + // later-registered check — snapshot compositions load replay before the app bundle that + // loads invariants. const ctx = new Context() await ctx.plugin(SessionStore) ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 94bd0cdd7e..913664cf2d 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -1,50 +1,5 @@ /** * Replay LLM plugin for snapshot tests. - * - * Installs a single `llm/stream` waterfall listener that short-circuits the - * waterfall (never calls `next()`) and yields model streams reconstructed from - * a recorded **session JSONL** fixture — so a snapshot test can boot the real - * agent against a fixed model transcript with no API key. See - * docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. - * - * The fixture IS the persisted session log (`/session.jsonl`): its - * `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by - * `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model - * call per loop step — see packages/core/agent-loop/src/loop.ts). Recording is - * therefore "run the real agent once and harvest the `.jsonl`", done by the - * snapshot harness — this plugin does not record. A fixture may carry its - * `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness - * pins that content in one scenario and scrubs the rest); replay is - * indifferent — derivation reads ONLY `assistant/chunk` events and the line-0 - * session header. - * - * A NESTED-agent scenario records more than one log: the parent plus one per - * in-process subagent (each subagent runs as its own {@link Session} on the same - * context). Replay loads them all ({@link loadSessionScripts}), derives a script - * per recorded session, and keys each live call by its calling session id - * (`GenerateOptions.sessionId`, stamped by the loop). Live session ids are fresh - * random values, so a live session binds to a recorded script by FIRST-CALL - * order (parent first — it streams before it delegates); see - * {@link installLlmReplay}. - * - * Two failure modes are NOT reconstructable from `assistant/chunk` alone — a - * pure throw before any chunk (e.g. an HTTP 401: the log holds only a - * `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content). - * A scenario that needs those supplies an optional sidecar - * (`/replay.override.json`: a `ReplayEntry[]`) that REPLACES the - * derived script. - * - * It lives in its own package (not under `examples/`) so its derive/parse/ - * replay logic falls under the per-file 100% coverage gate on package `src` - * trees — its tests previously lived under `examples/`, which the gate does - * not measure, leaving these branches (clean chunks / mid-stream throw / hang) - * unguarded. Its consumer is the ACP snapshot harness in `examples/acp-agent`, - * which loads it (via `cordis.snapshot.yml`) in place of a real LLM adapter. - * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default - * export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`, - * so a stray default would drop the namespace — see docs/postmortem/0001). - * * @module @deepseek-ai/dsh-llm-replay */ @@ -56,21 +11,11 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' /** - * One recorded model call. A discriminated union (not a bare `StreamChunk[]`) - * so it can faithfully replay BOTH branches of the documented LLM failure - * contract — an adapter may THROW from `stream()` or end with a `finish` error - * chunk — plus a `hang` marker for cancellation scenarios (mirrors the - * `MockAdapter` `hang` support in packages/core/agent-loop/tests). - * - * A `throw` entry carries any `chunks` the adapter emitted BEFORE it threw, so - * a mid-stream transport failure (partial output then `STREAM_CLOSED`) replays - * the partial chunks first and only then throws — exactly what the agent loop - * saw live (it may already have emitted partial assistant chunks). - * - * The normal/finish-terminated cases are DERIVED from the session JSONL - * ({@link deriveReplayScript}); only the throw and hang cases need a - * hand-authored sidecar entry (a thrown stream leaves no terminal `finish` in - * the log, so it cannot be derived as `chunks`). + * One recorded model call. A discriminated union (not a bare `StreamChunk[]`) so it can + * faithfully replay BOTH branches of the documented LLM failure contract — an adapter may + * THROW from `stream()` or end with a `finish` error chunk — plus a `hang` marker for + * cancellation scenarios (mirrors the `MockAdapter` `hang` support in + * packages/core/agent-loop/tests). */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } @@ -145,14 +90,12 @@ export function parseSessionLog(text: string): SessionEvent[] { } /** - * Read the identifying facts off a session log's header line (line 0): the - * recorded session `id` (diagnostics), `createdAt` (the deterministic ordering - * key that binds a recorded script to a live session — see - * {@link SessionScript}), and `seedLength` (the seed boundary — how many leading - * events were INHERITED via a fork seed rather than produced by this session's - * own model calls; absent ⇒ 0). A header missing a field falls back to a stable - * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is - * header-only and still orders fine as the single (primary) script. + * Read the identifying facts off a session log's header line (line 0): the recorded session + * `id` (diagnostics), `createdAt` (the deterministic ordering key that binds a recorded script + * to a live session — see {@link SessionScript}), and `seedLength` (the seed boundary — how + * many leading events were INHERITED via a fork seed rather than produced by this session's + * own model calls; absent ⇒ 0). + * * @param text - the raw `.jsonl` file contents (only the header line is read). * @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent. */ @@ -169,21 +112,6 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe /** * Reconstruct the per-`stream()` replay script from a recorded session log. * - * The agent loop makes exactly one `ctx.llm.stream()` call per step and appends - * every chunk as an `assistant/chunk` event tagged with the current - * `(turn, step)`. Grouping those events by `(turn, step)` in log order - * therefore yields one `{kind:'chunks'}` entry per model call, in call order. - * - * A group is only valid if it ends in a `finish` chunk — the adapter contract - * guarantees a successful (or finish-error) stream terminates with `finish`, - * and the loop relies on it. A group WITHOUT a terminal `finish` is the - * fingerprint of a *thrown* `stream()` (the loop recorded the prefix chunks, - * then an `error`/`turn/end`, but no `finish`): such a stream cannot be - * faithfully replayed as `{kind:'chunks'}` (that would look like a clean stop), - * so deriving it is an error — the scenario must supply a `replay.override.json` - * sidecar with an explicit `throw` (or `hang`) entry instead. {@link - * deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing - * override fails loud rather than silently replaying a thrown call as success. * @param events - the recorded session's events; only `assistant/chunk` is consulted. * @returns one `chunks` entry per recorded model call, in call order. */ @@ -242,17 +170,9 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { } /** - * Load every recorded session's script for a scenario, ordered by `createdAt` - * (earliest first), ready to bind to live sessions in first-call order. + * Load every recorded session's script for a scenario, ordered by `createdAt` (earliest + * first), ready to bind to live sessions in first-call order. * - * The PRIMARY session (`config.file`, with its optional `overrideFile`) is the - * parent; each `config.childFiles` entry is a recorded subagent session. A - * single-session scenario has no `childFiles`, so this returns one script and - * behaves exactly like the old single-cursor replay. The primary always sorts - * first when ties occur (a sub-millisecond parent/child `createdAt` collision): - * the parent issues the FIRST model call (it must stream before it can delegate - * in the synchronous nested cut), so binding it to the first live session is - * correct regardless of a timestamp tie. * @param config - the fixture paths: the primary log plus any recorded child logs. * @returns the primary script first, then the child scripts in bind order. */ @@ -274,12 +194,8 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { } const text = readFileSync(childFile, 'utf8') const header = parseSessionHeader(text) - // Derive the child's script from its OWN events only — events AT OR AFTER - // the seed boundary. A FORK child's log begins with the seeded parent prefix - // (the parent's events, including its `assistant/chunk`s); replaying those as - // the child's model calls would feed the child the PARENT's recorded - // responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op - // there. + // Derive the child's script from its own events only — events AT OR after the seed + // boundary. const ownEvents = parseSessionLog(text).slice(header.seedLength) children.push({ recordedId: header.id, @@ -288,20 +204,8 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { primary: false, }) } - // The primary (parent) always binds first — it issues the first model call, - // because it must run a turn before it can delegate. Children follow in - // createdAt order. In the current synchronous cut sibling children are created - // STRICTLY SEQUENTIALLY — the subagent tool awaits one child's result and - // disposes it before the parent's next tool call can start the next — so their - // createdAt values are strictly ordered and match first-call order exactly. - // The recordedId tiebreak only makes a degenerate same-millisecond collision - // (unreachable in this cut) deterministic; it does NOT recover first-call - // order, so it is arbitrary if such a tie ever occurs. - // XXX(concurrent-subagents): a future cut that runs siblings concurrently or - // backgrounded could create two children in the same millisecond, where this - // createdAt+id order may diverge from first-call order. That cut must thread a - // real first-call ordinal (the order live sessions first stream) instead of - // leaning on createdAt — see the per-session-replay RFC. + // Synchronous children start in creation order; the id only stabilizes timestamp ties. + // XXX(concurrent-subagents): concurrent children need an explicit first-call ordinal. children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId)) return [primary, ...children] } @@ -344,31 +248,10 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) } /** - * Install the replay `llm/stream` listener on `ctx`. Returns the listener - * disposer (so a fiber dispose removes it — HMR safety). Exported separately - * from {@link apply} so unit tests can drive it without the Loader or env vars. + * Install the replay `llm/stream` listener on `ctx`. Returns the listener disposer (so a fiber + * dispose removes it — HMR safety). Exported separately from {@link apply} so unit tests can + * drive it without the Loader or env vars. * - * Replay is PER-SESSION POSITIONAL: each recorded session has its own script - * (parent + any subagent children, loaded by {@link loadSessionScripts} ordered - * by `createdAt`), and the Nth `stream()` call FROM A GIVEN SESSION serves that - * session's Nth entry. The calling session is read off `options.sessionId` (the - * agent loop stamps it from `agent.session.id`). - * - * Live session ids are freshly random and never equal the recorded ones, so a - * live session binds to a recorded script by FIRST-CALL ORDER: the first live - * session to make any call takes the first ordered script (the parent — earliest - * `createdAt`, and the first to stream because it must run before it delegates), - * the next new live session takes the next script, and so on. This keys by WHO - * calls rather than global call order, so it stays correct even if subagents - * ever run concurrently/backgrounded (a global cursor would interleave them). - * - * A call with no `sessionId` (a direct unit-test `ctx.llm.stream` that omits it) - * is treated as one anonymous session — it binds to the first script, so the - * single-session path behaves exactly as the old global cursor did. - * - * Each per-session cursor advances synchronously at listener-invocation time - * (not lazily inside the generator) so call ORDER within a session, not - * iteration order, fixes the mapping. * @param ctx - the context whose `llm/stream` waterfall the listener short-circuits. * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job). * @returns the `ctx.on` disposer that removes the listener. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 87cc62d165..8535ab604b 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -427,11 +427,8 @@ describe('loadSessionScripts', () => { }) it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => { - // A fork child's log begins with the seeded parent prefix — the parent's - // events, INCLUDING its assistant/chunk events. Deriving the child script - // from the whole log would replay the PARENT's recorded responses as the - // child's model calls. With seedLength recorded, the child script must - // contain only the child's OWN chunks (those after the boundary). + // A fork child's log begins with the seeded parent prefix — the parent's events, INCLUDING + // its assistant/chunk events. const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' } const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }] const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) @@ -488,11 +485,7 @@ describe('loadSessionScripts', () => { }) it('keeps the primary first even when a child sorts BEFORE it in input order', () => { - // The primary is appended first internally but the child has an EARLIER - // createdAt — the primary must still win on the tie-break against a - // later-but-equal child, and lose only to a genuinely earlier child via - // createdAt (here the child is earlier, so order is child-then-primary only - // if createdAt strictly less; equal createdAt keeps primary first). + // Equal creation times keep the primary first regardless of input order. const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS]) const scripts = loadSessionScripts({ file: f, childFiles: [earlier] }) diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 715c7451d7..10776f4389 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -1,13 +1,6 @@ /** - * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a - * model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a - * test drive the service and the model-facing tool through the REAL cordis - * Loader / export path, exercising registration, capability validation, the - * run lifecycle, and the structured-output branch deterministically. - * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default — - * a functional plugin (it only registers a provider; it is never injected). - * + * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a model or a real + * child agent. * @module @deepseek-ai/dsh-subagent-mock */ diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index ddd725da4b..25ce1ef629 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -83,10 +83,7 @@ describe('dsh-subagent-mock', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray - // `export default apply` would collapse the module via `unwrapExports` - // (`exports.default ?? exports`), DROP `inject`, and crash at load with - // "cannot get property … without inject". Guard the shape directly. + // Loader must retain this namespace's injection metadata. expect('default' in mock).toBe(false) expect(mock.name).toBe('subagent-mock') expect(mock.inject).toEqual(['subagents']) diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index 2e9ed0d635..30d67dd434 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -1,34 +1,5 @@ /** - * `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout ENFORCER. It registers - * ONE `tools/execute` around-dispatch listener that, for a tool declaring a - * `timeoutMs` on its {@link ToolDefinition}, arms a per-call deadline on - * `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline - * wins. The budget is DECLARED by the tool (see `ToolDefinition.timeoutMs`, set - * by the owning tool plugin from its own config); this plugin only enforces it, - * so it is zero-config and there is no tool-name map to mistype. - * - * This is a COOPERATIVE deadline, not a hard kill: the derived signal only - * NOTIFIES. A tool that declares `timeoutMs` (and the capability it forwards - * `exec.signal` to) must honor that signal and reach quiescence — the plugin - * never races the tool promise or terminates work itself (see the timeout-library - * RFC's rejection of `Promise.race`). Declaring `timeoutMs` therefore MEANS "this - * tool is cooperative with `exec.signal`": a tool that ignores the signal will - * not stop on timeout, so only signal-forwarding tools should declare it (the - * shipped web tools are the reference). - * - * Ownership of the `TOOL_TIMEOUT` code is entirely here: it is both the internal - * {@link deadline} code (so {@link timeoutOf} scopes the classification to THIS - * plugin's own timer, reading a foreign/nested outer deadline as an ordinary - * cancel) and the structured `{ name, code }` on the replacement tool result. - * No new session event is needed for reconstructability: the `TOOL_TIMEOUT` - * result IS the final model-facing `tool/result`, already logged by the loop. - * - * Why a `tools/execute` around seam and not a `pre`/`post` pair: the deadline - * needs ONE lexical scope — arm on `exec.signal`, delegate to dispatch, classify - * the result, dispose the timer — which the around seam gives directly. A - * pre/post split would spread one deadline's lifetime across two independent - * waterfalls (a call-id map, cleanup on every deny/throw/dispose path). - * + * `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout policy. * @module @deepseek-ai/dsh-timeout-policy */ @@ -71,22 +42,7 @@ export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecut } /** - * Register the tool-call timeout enforcer. For a tool whose {@link ToolDefinition} - * declares `timeoutMs`, the listener arms a {@link deadline} on the caller's - * `exec.signal`, swaps it onto `exec` for the downstream dispatch (cordis - * `next()` ignores passed arguments, so a wrapper mutates the shared `exec` in - * place), restores the original signal afterward so `tools/post-execute` sees the - * caller's own signal, and replaces the result with {@link toolTimeoutResult} - * when its own timer fired. A tool that declares no budget delegates untouched. - * - * The budget source is the tool's own declaration read from the registry - * (`ctx.tools.get(exec.name, exec.agent)?.timeoutMs`), NOT a plugin config map — - * `exec.name` is the tool being dispatched, so the lookup always resolves and - * there is no mistypable tool name and no unknown-name path to warn or throw - * about. Resolution goes through the CALLER's visible view (the `exec.agent` - * scope), exactly like dispatch itself: a scoped tool's own `timeoutMs` governs - * its calls, and a global name-twin's budget is never misapplied to a shadowing - * per-agent variant. + * Register the tool-call timeout enforcer. */ export function apply(ctx: Context): void { ctx.on('tools/execute', async (exec, next): Promise => { diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index d2175f49b9..19def206c5 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -1,21 +1,6 @@ /** - * The model-facing `todo_write` tool: the agent's whole task list, replaced - * wholesale on each call. Every call appends a `todo/write` event (the full - * list snapshot) to the calling agent's session log via - * `exec.agent.session.append('todo/write', { todos })`; the current list is the - * most recent such event (last-write-wins on replay). UIs render off - * `session/event`: the stdio UI prints the checklist, the ACP bridge maps it to - * a `plan` sessionUpdate. - * - * Single owner: the list belongs to the ONE agent session that called the tool. - * There is no subagent/shared/swarm scope — a non-agent caller (no - * `exec.agent`) has nowhere to write the list and is rejected. - * - * 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 model-facing `todo_write` tool: the agent's whole task list, replaced wholesale on each + * call. * @module @deepseek-ai/dsh-tool-todo */ @@ -42,20 +27,8 @@ const DESCRIPTION = + '(not started), `in_progress` (being worked on now), `completed` (finished).' /** - * Validate the value constraints the SchemaSpec can't express and build the - * canonical {@link TodoItem}[]. - * - * `defineTool` already validates type/required/enum before `execute` runs (a - * bad `status` is rejected by the registry's `validateArgs`, never reaching - * here), so `status` is guaranteed to be one of the three enum literals. But - * `InferArgs` maps an `enum` string prop to plain `string`, so the compiler sees - * `args.todos` as `{ content: string; status: string }[]`; the - * `status as TodoItem['status']` narrowing records that registry guarantee - * rather than re-checking it (an unreachable re-check would be dead code the - * coverage gate would flag). What remains is the - * value rules the DSL has no vocabulary for: non-empty unique content (stored - * trimmed, so the persisted value matches the dedupe/length key), and at most - * one `in_progress` task. + * Validate the value constraints the SchemaSpec can't express and build the canonical {@link + * TodoItem}[]. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 86e8814633..bdb14a5518 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -149,10 +149,7 @@ describe('dsh-tool-todo', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // Postmortem 0001 guard: this plugin HAS `inject = ['tools']`, so a stray - // `export default apply` would collapse the module via `unwrapExports` - // (`exports.default ?? exports`), DROP `inject`, and crash at load with - // "cannot get property … without inject". Guard the shape directly. + // Loader must retain this namespace's injection metadata. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-todo') expect(tool.inject).toEqual(['tools']) diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 1cbdff64d1..febb3e50ca 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -1,29 +1,8 @@ #!/usr/bin/env node /** - * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that - * loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter - * and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue — - * `.env` loading, the fail-loud Loader guards, snapshot-aware config - * resolution, the settle-the-tree boot sequence — lives in - * {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific - * lifecycle: - * - * - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never - * trigger a live model call. - * - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling - * `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of - * `llm-deepseek`). - * - In a snapshot run the harness closes stdin when done, so dispose the - * context (flushing persistence) and exit cleanly. In a normal editor - * session stdin stays open for the connection's lifetime (the editor kills - * the process), so the EOF handler never fires. - * - * IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to - * STDERR only (the app plugin loads no stdout logger, and the shared guards - * write to stderr); a stray stdout write corrupts the protocol frames. - * - * Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`). - * + * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that loads the {@link + * @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter and a bash executor), speaking + * ACP JSON-RPC on stdio. * @module @deepseek-ai/dsh-acp-agent/bin */ diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 27b919f756..7c6ae6ba69 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -1,32 +1,7 @@ /** - * The ACP server app: the default agent spine ({@link - * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP - * server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp} - * bridge, and DELIBERATELY NOTHING that writes to stdout. - * - * The cluster is the OPPOSITE of {@link @deepseek-ai/dsh-stdio-agent}'s, and - * baking it in is the whole point: an ACP server speaks JSON-RPC on stdout, so - * a stray console logger would corrupt the protocol frames (the [stdout-purity - * footgun]). This package contains NO console-logger entry, NO `hmr` (the editor - * owns the subprocess), and pre-creates NO agents (ACP `session/new` creates - * them on demand) — so the default front door has no logger entry to get wrong. - * (A leaf `cordis.yml` could still add a sibling `@cordisjs/plugin-logger-console`, - * which this app does not prevent — so the rule "never add a stdout logger to an - * ACP leaf" still stands; the app just gives the leaf nothing to misconfigure.) - * - * The leaf supplies the swappable backends: the LLM adapter (`llm-deepseek` for - * the real model, `llm-replay` for keyless snapshot replay), the bash executor - * (`bash-local`), and any optional product tools it wants to expose. This app's - * {@link Config} (model, system prompt, persistence root) routes each value to - * where it is wired — model/prompt onto the bridge's per-session agent - * template, the root onto the JSONL backend. - * - * 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` and drop the `Config` - * namespace (see docs/postmortem/0001 — the exact bug that shipped here once). - * The keyless ACP snapshot/Loader-path tests guard this end-to-end. - * + * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the + * coupled front-door cluster an ACP server needs — JSONL session persistence and the {@link + * @deepseek-ai/dsh-acp} bridge, and deliberately NOTHING that writes to stdout. * @module @deepseek-ai/dsh-acp-agent */ diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 537155429c..7b3ce00a58 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -139,14 +139,7 @@ describe('dsh-acp-agent composition', () => { }) 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, so that collapse would NOT crash at load (the keyless - // bin smoke would still answer `initialize`) — it would silently lose its - // config schema. So guard the shape directly here: assert no `default` - // export, and that the real `unwrapExports` leaves `name`/`Config`/`apply` - // intact. Adding `export default` to src/index.ts fails this test. + // Loader must retain the namespace so name, Config, and apply survive unwrapping. expect('default' in acpAgent).toBe(false) expect(typeof acpAgent.apply).toBe('function') diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index de4612a40a..16945708ea 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -18,20 +18,9 @@ import { Readable, Writable } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' /** - * BUILT-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` - * boots `src/bin.ts` under tsx — but the package's `bin` field points at - * `lib/bin.js`, run under plain `node` by a real consumer. This runs the REAL - * `lib/bin.js` under `node` (NOT tsx) and asserts it answers an `initialize` - * JSON-RPC frame, so a regression in the published entry (a settle race that - * exits before the bridge attaches, a stdout logger leaking onto the protocol) - * fails here. - * - * It build-gates: SKIPS if `lib/bin.js` is absent (suite run without - * `pnpm run build`); CI runs it after the build step. Setup mirrors a real - * install (a temp dir whose `node_modules` symlinks the built packages) and runs - * `node --expose-internals` (the cordis Loader resolves bare plugin specifiers - * via its internal module loader, active only under that flag). KEYLESS: - * `initialize` never reaches the model; a dummy key lets `llm-deepseek` boot. + * Built-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` boots + * `src/bin.ts` under tsx — but the package's `bin` field points at `lib/bin.js`, run under + * plain `node` by a real consumer. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -48,12 +37,7 @@ const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', 'schemastery', 'cosmokit', ] -// Third-party deps the ACP bridge needs at runtime. They are declared by -// `dsh-acp` (NOT by `acp-agent`), so they live under `packages/ui/acp/node_modules` -// and are NOT necessarily hoisted where THIS test file can resolve them — pnpm's -// strict layout only exposes a package's deps under that package. Resolve each -// from the `ui/acp` package directory (the one that declares it) so the lookup -// works regardless of hoisting, then symlink it into the consumer for plain node. +// Third-party deps the ACP bridge needs at runtime. const npmDeps = ['@agentclientprotocol/sdk', 'zod'] const acpPkgDir = join(repoRoot, 'packages/ui/acp') @@ -161,18 +145,14 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A typo'd config path must fail clearly, not exit 0. The include plugin - // itself cannot be imported from a non-existent dir; the Loader logs that and - // leaves the entry with no fiber, which boot()'s entry-load check throws on. + // A typo'd config path must fail clearly, not exit 0. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml') expect(code).not.toBe(0) expect(stderr).toContain('failed to load') }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { - // The directory exists (the include imports), but the file does not — the - // include's init throws "config file not found", which surfaces as an - // unhandled rejection the fail-loud guard turns into a non-zero exit. + // Existing directory plus missing config exercises the include plugin's fail-loud path. consumer = await makeConsumer() const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer) expect(code).not.toBe(0) diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/ui/acp-agent/tests/load-path.e2e.ts index ec251a6e6b..85545abd67 100644 --- a/packages/ui/acp-agent/tests/load-path.e2e.ts +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -17,22 +17,9 @@ import { } from '@agentclientprotocol/sdk' /** - * REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its - * own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and - * `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is - * the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that - * bypasses `unwrapExports`, the exact path that once dropped the bridge's - * `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP - * operations end-to-end: `initialize` → `session/new` → `session/load`. - * - * KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never - * the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key - * lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree. - * - * The config is written into a temp dir whose cwd IS the session workspace, so - * the bash workdir validation passes. We point tsx at the repo-root tsconfig - * (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the - * unbuilt `paths` map is found by searching UP from cwd. + * real-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its own `bin` (the + * demo:acp entry) as a subprocess, driving the cordis Loader and `unwrapExports` over a + * minimal `cordis.yml` that loads THIS package. */ const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) @@ -131,13 +118,8 @@ describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => { const { sessionId } = await client.newSession({ cwd, mcpServers: [] }) expect(sessionId).toBeTruthy() - // session/load reaches the resume FACTORY + persistence without the model: - // load an UNKNOWN id (loading the live `sessionId` would correctly reject as - // "already loaded"). The bridge consults `sessionPersistence.list()` then - // `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE - // the bridge's inject scope — the exact path postmortem 0001 crashed. A - // healthy tree rejects with a not-found error; a broken export shape would - // instead throw "cannot get property … without inject" before reaching it. + // session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN + // id (loading the live `sessionId` would correctly reject as "already loaded"). const unknownId = '00000000-0000-4000-8000-000000000000' await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then( () => { throw new Error('expected session/load of an unknown id to reject') }, diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 2f1f35adc4..254a463047 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in ## Session config options -The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6. +The bridge advertises `sandbox-mode` and `approval-policy` only when their services are composed. Current values fold from each session's log over the composition default, so load restores overrides directly. `session/set_config_option` validates against the closed vocabulary, calls the domain writer, and returns refreshed state. Changes inside an open turn append immediately; idle changes are coalesced in memory and anchored at the next `agent/prompt-submit`, preserving turn enclosure and event order. A crash before anchoring discards the pending change, and load reports durable log truth. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. @@ -56,7 +56,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t - `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). - `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. -`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. +`presentResult` returns a generic, terminal, or diff card. The bridge switches on `view.card`; absent presentation falls back to a generic card without inspecting the tool name. Foreground bash uses terminal cards, filesystem writes and edits use diff cards, and reads use generic cards with locations. File-card titles are relativized against the session cwd, while `locations` and diff paths remain raw so clients can open the real file. Result content replaces the pending call card, so successful mutations always provide their final diff. The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 3830dba676..c84333ff40 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -1,11 +1,5 @@ /** - * Pure translation between harness vocabulary and ACP wire types. No I/O, no - * Cordis context — every function here is total and unit-testable in isolation. - * Keeping the mapping pure is deliberate: the SDK rejects an unknown - * `stopReason`, so the {@link turnEndToStopReason} total function (with its - * exhaustive test over every `TurnEndReason` kind) is the guard that a turn - * always settles to a legal wire value. - * + * Pure translation between harness vocabulary and ACP wire types. * @module @deepseek-ai/dsh-acp/codec */ @@ -16,29 +10,6 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr /** * Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum. * - * The mapping is total over the kinds the loop actually produces today - * (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`). - * `TurnEndReason` is - * merge-extensible, so an unknown future kind falls through to `end_turn` — - * the safest default (the turn DID end; we just lack a more specific wire - * reason) — rather than throwing into the SDK, which would reject an unknown - * `stopReason` and break the prompt RPC. When a new kind gains a dedicated ACP - * reason (e.g. a future `refusal` → `refusal`), add an explicit case here. - * - * - `completed` → `end_turn` (the model chose to stop) - * - `max-tokens` → `max_tokens` (cut off at the output-token ceiling) - * - `aborted` → `cancelled` (a step abort or a queue-aware `agent.cancel()`, e.g. from `session/cancel`) - * - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the - * `session/prompt` RPC on an error turn BEFORE calling this, so - * a client sees a JSON-RPC error, not a stop reason — see - * `rejectPrompt` in index.ts. This case keeps the function total - * for any non-bridge caller / property test.) - * - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a - * cancellation from the client's perspective) - * - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit` - * hook before any step ran — ACP has no "rejected" reason, and a - * blocked prompt is, from the client's view, the prompt not being - * carried out; `cancelled` is the closest legal wire reason) * @param reason - the harness turn-end reason to translate. * @returns the legal ACP wire value per the mapping above. */ @@ -56,10 +27,9 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'cancelled' case 'error': return 'end_turn' - // Merge-extensible: an unknown future TurnEndReason kind still has to - // produce a legal wire value (the SDK rejects unknown stopReason), so - // default to end_turn rather than assertNever. Add an explicit case when a - // new kind gains a dedicated ACP reason. + // Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire + // value (the SDK rejects unknown stopReason), so default to end_turn rather than + // assertNever. default: return 'end_turn' } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a3ce9b5117..40e5f0fde1 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,37 +1,7 @@ /** - * The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that - * exposes the harness agent as an ACP server over JSON-RPC stdio, so editors - * (Zed and other ACP clients) can drive it. The structured analogue of the - * readline `stdio-chat` plugin. - * - * This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes - * the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, - * and `dsh-session-persistence` (for `session/load`). It maps: - * - * - `initialize` → protocol-version negotiation, text-only capabilities - * - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })` - * - `session/load` → `ctx.agents.resume(...)` then replay the event log - * - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn - * that ends in `error` rejects the RPC) - * - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a - * running step, clears queued + steering work, and drops a - * turn about to start) + settle the in-flight prompt - * - * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an - * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every - * `session/event` and `agent/*` event is routed strictly to its owning session - * record, so two sessions streaming at once never interleave their - * `session/update` notifications. Permission prompts ride the same ownership - * map: the bridge answers `approval/request` for its own agents over - * `session/request_permission` (see the approval answerer below) — whether a - * call ASKS is policy (a hook or plugin returning `ask`), not the bridge's. - * - * stdout is the protocol: this plugin must run in an example that loads NO - * stdout logger (the console logger writes to stdout and would corrupt the - * JSON-RPC frames). The guarantee is config-only — see the package README and - * RFC 010 § Risks. - * + * The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that exposes the harness + * agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive + * it. The structured analogue of the readline `stdio-chat` plugin. * @module @deepseek-ai/dsh-acp */ @@ -102,11 +72,7 @@ import { } from './codec.ts' export const name = 'acp' -// The bridge programs against the interface packages only (architecture rule: -// plugins never depend on dsh-agent-loop). `sessionPersistence` is required -// because `initialize` advertises `loadSession: true`. `tools` lets a tool own -// how its calls render (`presentCall`/`presentResult`); the bridge looks up the -// definition by name and falls back to a generic presentation when absent. +// Persistence enables loadSession; tools own call and result rendering. export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] /** @@ -295,24 +261,9 @@ interface SessionRecord { */ terminalEnabled: boolean /** - * The in-flight `session/prompt`, or `undefined` when none is pending. A - * prompt resolves with a {@link StopReason} or rejects with an Error (a - * turn that ended in failure). Settled exactly once via {@link settlePrompt}. - * - * `turn` is the loop turn number this prompt owns, captured from the log's - * `turn/start` after `send()`. Until then it is `undefined` (the turn has not - * begun). Only a `turn/end` whose turn number equals `turn` settles the prompt - * — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end - * arrives after the next prompt is already installed) can never settle the - * wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot, - * so a later stale `turn/end` finds no pending prompt. - * - * `logWatermark` is the session log length at the moment the prompt was - * installed (before `send()`). The settle-from-log fallback uses it to infer - * the owning `turn/start` from the canonical log even when the live - * `session/event` capture was starved (a peer listener that throws on - * `turn/start` — see `settleFromLog`): the prompt owns the FIRST `turn/start` - * appended at or after this watermark. + * The in-flight `session/prompt`, or `undefined` when none is pending. A prompt resolves + * with a {@link StopReason} or rejects with an Error (a turn that ended in failure). Settled + * exactly once via {@link settlePrompt}. */ inflight: { resolve: (reason: StopReason) => void @@ -321,38 +272,17 @@ interface SessionRecord { logWatermark: number } | undefined /** - * Config switches accepted while the session was IDLE, not yet anchored in - * its log. The turn-enclosure contract makes a bare between-turns append - * invalid (the JSONL backend treats a post-`turn/end` tail as crash - * garbage, and dev invariants throw), so an idle switch waits here and is - * anchored at the next turn's prompt-submit — before anything in that - * turn assembles a prompt or runs a call, and last write - * per knob wins (an idle flip-flop anchors as one event). Until anchored, - * the switch lives only in bridge memory: the set/new/load responses - * overlay it truthfully, and a restart before the next turn reverts it — - * which `session/load` then reports honestly from the log's fold. + * Config switches accepted while the session was IDLE, not yet anchored in its log. */ pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy } } /** - * Drive the in-flight prompt's settle from the harness event stream. The bridge - * settles off the durable log: the `turn/end` session event on the - * `session/event` feed for the prompt's own turn, with the agent - * erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor - * cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener - * starved the bridge's listener before it saw the boundary. The first of these - * to fire settles the prompt; `settle` is then cleared so the others are no-ops - * (settle-exactly-once). + * Drive the in-flight prompt's settle from the harness event stream. */ export function apply(ctx: Context, config: AcpConfig): void { - // Capture the injected services NOW, during apply(), while we are inside this - // plugin's fiber (where `inject` grants access). The ACP method handlers run - // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is - // NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` / - // `ctx.sessionPersistence` lazily inside a handler throws "cannot get property - // … without inject". Resolving the references here and closing over them keeps - // the handlers working regardless of which fiber later invokes them. + // Capture the injected services NOW, during apply(), while we are inside this plugin's fiber + // (where `inject` grants access). const agents = ctx.agents const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger @@ -362,25 +292,16 @@ export function apply(ctx: Context, config: AcpConfig): void { // this warn sink so a throwing tool presenter is logged, not propagated. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) - // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId - // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). - // The two stay in lockstep: a record is added to `sessions` and the agent to - // `bySession` together, and removed together. + // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId reverse map so + // `agent/*` events (which carry only the Agent) demux in O(1). const sessions = new Map() const bySession = new WeakMap() - // Session ids whose `session/load` is mid-`resume()` (the slot is reserved - // before the async resume so a pipelined load/new for the SAME id can't create - // two agents). Distinct ids load concurrently; a given id loads once at a time. + // Session ids whose `session/load` is mid-`resume()` (the slot is reserved before the async + // resume so a pipelined load/new for the same id can't create two agents). const loadingIds = new Set() - // Set once the bridge has torn down (disposal or client disconnect). An async - // `session/load` mid-`resume()` when teardown ran must observe this after its - // await and NOT install a record (which would resurrect a live agent/listeners - // after the bridge closed). Checked after every load await. + // Set once the bridge has torn down (disposal or client disconnect). let closed = false - // Whether the client advertised the Zed `_meta.terminal_output` capability in - // `initialize`. When true, a tool's terminal presentation is rendered as a - // terminal card (content + `_meta.terminal_*`); when false, the bridge uses - // the tool's text fallback. Set once in `initialize`, read on every tool event. + // Whether the client advertised the Zed `_meta.terminal_output` capability in `initialize`. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -448,10 +369,7 @@ export function apply(ctx: Context, config: AcpConfig): void { /** Push a `session/update` notification, swallowing post-close rejections. */ const notify = (notification: SessionNotification): void => { - // sessionUpdate returns a promise; a closed connection rejects it. The - // update is best-effort UI feed, never load-bearing for correctness, so a - // throwing/rejecting send must not break the turn (the chunk is emitted - // inside the model step — see docs/defensive-patterns.md "contain callback exceptions"). + // sessionUpdate returns a promise; a closed connection rejects it. /* v8 ignore next 3 -- the rejection only fires on a stdout/connection write failure (closed pipe), which the in-memory test transport never induces; the swallow is a defensive best-effort guard like the loop's emit traps */ @@ -482,22 +400,10 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- Stream the harness event taxonomy to ACP session/update -------------- - // All content streaming AND the prompt settle flow through `session/event`, - // the canonical log: every assistant/chunk and tool/call/result is logged, so - // translating from the log makes live streaming and `session/load` replay - // share the identical path (streamSessionEventUpdate). Both the owning-turn - // capture and the settle key off the log's own `turn/start`/`turn/end` — the - // durable boundary events (there is no agent/* turn mirror). `closeTurn` - // appends `turn/end` to the log unconditionally, and `turn/start` is appended - // before any step runs, so within this one listener we always see the - // prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A - // `turn/end` settles the prompt ONLY when it is the prompt's OWN turn - // (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn - // whose end arrives late is ignored (see - // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP - // has no error stop reason); other reasons resolve via the codec. Demux - // strictly by session id: a `session/event` is routed to its own record, so - // two sessions streaming at once never cross-settle or interleave updates. + // All content streaming AND the prompt settle flow through `session/event`, the canonical + // log: every assistant/chunk and tool/call/result is logged, so translating from the log + // makes live streaming and `session/load` replay share the identical path + // (streamSessionEventUpdate). ctx.on('session/event', (session, event: SessionEvent) => { const rec = sessions.get(session.header.id) if (rec === undefined) return @@ -508,14 +414,8 @@ export function apply(ctx: Context, config: AcpConfig): void { const inflight = rec.inflight if (inflight === undefined) return if (event.type === 'turn/start') { - // Tag the in-flight prompt with its owning turn — but ONLY a - // `message`-triggered turn (the kind a `send()` prompt produces). A turn - // a plugin opens between prompt-install and the prompt's own turn (an idle - // `agent.inject()` writes a one-shot `injection`-triggered turn) must NOT - // be mistaken for the prompt's turn, or its turn/end would settle the RPC - // early. The first message turn at/after install owns the prompt - // (`turn === undefined` guard); the loop batches queued messages into one - // turn, so there is exactly one. + // Tag the in-flight prompt with its owning turn — but only a `message`-triggered turn + // (the kind a `send()` prompt produces). if (inflight.turn === undefined && event.data.trigger.kind === 'message') { inflight.turn = event.data.turn } @@ -527,34 +427,21 @@ export function apply(ctx: Context, config: AcpConfig): void { settleFromTurnEnd(inflight, event.data.reason) }) - // Settle fallback: a `session/event` listener registered BEFORE ACP that - // throws (on `turn/start` OR `turn/end`) would, via cordis `emit`'s - // stop-on-throw, starve ACP's listener above — the prompt would hang or, if - // only the turn number was missed, settle as the wrong outcome. So when the - // agent settles to `idle` (or is disposed), reconcile against the canonical - // log: determine the prompt's owning turn (the captured `turn`, or — if the - // live capture was starved — the FIRST `turn/start` appended at/after the - // install-time `logWatermark`), then settle from that turn's `turn/end` - // (reject on error, resolve via codec), or `cancelled` if no owning turn ever - // started. Never double-settles — clears `inflight` first. + // Settle fallback: a `session/event` listener registered before ACP that throws (on + // `turn/start` OR `turn/end`) would, via cordis `emit`'s stop-on-throw, starve ACP's listener + // above — the prompt would hang or, if only the turn number was missed, settle as the wrong + // outcome. const settleFromLog = (rec: SessionRecord): void => { const inflight = rec.inflight if (inflight === undefined) return const events = rec.agent.session.events - // The owning turn number: the captured one, or — if the live capture was - // starved — inferred from the log as the first MESSAGE-triggered turn opened - // at/after the watermark. The message-trigger filter matches the live - // capture: a one-shot `injection` turn a plugin may open between - // prompt-install and the prompt's turn is NOT the prompt's turn. Undefined - // only if no message turn ever started for this prompt. + // The owning turn number: the captured one, or — if the live capture was starved — inferred + // from the log as the first MESSAGE-triggered turn opened at/after the watermark. const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find( (e): e is Extract => e.type === 'turn/start' && e.data.trigger.kind === 'message', )?.data.turn - // The owning turn's end in the log. If `owningTurn` is undefined (no turn - // ever started for this prompt — a torn-down-before-turn case that quiesce's - // direct settle normally pre-empts), no `turn/end` matches (turn numbers are - // >= 1) and `findLast` returns undefined, falling through to cancelled. + // The owning turn's end in the log. const end = events.findLast( (e): e is Extract => e.type === 'turn/end' && e.data.turn === owningTurn, @@ -568,10 +455,8 @@ export function apply(ctx: Context, config: AcpConfig): void { settleFromTurnEnd(inflight, end.data.reason) } - // On a settle to idle/disposed, reconcile any still-pending prompt from the - // log (covers a starved `session/event` listener — see settleFromLog). A mid- - // step disposal that never appended a clean turn/end resolves `cancelled`. - // Demux via the agent→sessionId reverse map. + // On a settle to idle/disposed, reconcile any still-pending prompt from the log (covers a + // starved `session/event` listener — see settleFromLog). ctx.on('agent/status', (agent, status: AgentStatus) => { const sessionId = bySession.get(agent) if (sessionId === undefined) return @@ -580,17 +465,9 @@ export function apply(ctx: Context, config: AcpConfig): void { if (status === 'idle' || status === 'disposed') settleFromLog(rec) }) - // --- Approval answerer ----------------------------------------------------- - // The bridge is the approval channel for the agents it owns: an `ask` routed - // through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes - // an editor permission prompt attached to the already-streamed tool call. The - // listener occupies the single decision slot ONLY for its own agents — a - // foreign or call-less request delegates via next() so another answerer (or - // the fail-closed `unavailable` default) takes the question. A rejected - // `requestPermission` (client gone, bridge torn down) propagates and the - // ApprovalService contains it as `unavailable`. Options are one-shot only: - // allow_always is a grant-storage design the approval RFC defers, so the - // prompt never offers a durable grant the harness could not honor. + // --- Approval answerer The bridge is the approval channel for the agents it owns: an `ask` + // routed through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes an editor + // permission prompt attached to the already-streamed tool call. ctx.on('approval/request', (req, next) => { const sessionId = bySession.get(req.agent) // The protocol requires `toolCall` (the prompt renders attached to it), so @@ -614,17 +491,11 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- The ACP Agent method surface ----------------------------------------- /** - * The session config options this composition can honor, with current - * values folded from the AGENT'S OWN session log (`effectiveSandboxMode` / - * `effectiveApprovalPolicy` — the log is the per-session store, so a - * `session/load` reports a resumed session's overrides with no catch-up - * machinery), overlaid with the record's not-yet-anchored pending switches - * (see {@link SessionRecord.pendingSwitches}). Capability-gated like every - * advertised lever: the sandbox option exists only when the mounted - * executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval - * option only when the approval seam is composed — both read - * opportunistically so this bridge keeps working in compositions without - * them. + * The session config options this composition can honor, with current values folded from the + * AGENT'S own session log (`effectiveSandboxMode` / `effectiveApprovalPolicy` — the log is + * the per-session store, so a `session/load` reports a resumed session's overrides with no + * catch-up machinery), overlaid with the record's not-yet-anchored pending switches (see + * {@link SessionRecord.pendingSwitches}). */ const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => { const options: SessionConfigOption[] = [] @@ -694,15 +565,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } } - // Idle-accepted switches anchor at the next turn's prompt-submit: the turn - // is open (the seam fires inside it, per drained message — the first flush - // empties the slot, later ones no-op), the loop has not yet assembled - // anything for it, and — unlike appending from inside a `session/event` - // listener — this seam fires OUTSIDE any log emit, so peer listeners - // (the dev invariants, persistence) observe the anchored events in strict - // log order. A turn with no prompt (an idle inject's one-shot injection - // turn) leaves the switch pending — it runs no step, so nothing executes - // or assembles under a stale value. + // Anchor idle switches during prompt-submit so persistence observes ordered in-turn events. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { const sessionId = bySession.get(agent) const rec = sessionId === undefined ? undefined : sessions.get(sessionId) @@ -755,10 +618,7 @@ export function apply(ctx: Context, config: AcpConfig): void { meta: { cwd: params.cwd }, agentOptions: agentOptions(config), }) - // Creation is now asynchronous because it awaits the unpublished setup - // transaction. A client disconnect can therefore close this bridge - // after the entry check but before the handle resolves; never install a - // post-close record that quiesce() could not have seen. + // Creation is now asynchronous because it awaits the unpublished setup transaction. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC immediately on close; real stdio may let the handler resume */ if (closed) { @@ -789,25 +649,13 @@ export function apply(ctx: Context, config: AcpConfig): void { } validateWorkspaceParams(params) validateMcpServers(params) - // Reserve THIS id's load slot BEFORE the await. Without it, two pipelined - // loads for the same id could both pass the guard above while the first - // resume() is pending, then both install a record and leak a second - // agent. (Distinct ids load concurrently — the set is keyed by id.) The - // slot is released in `finally` so a rejected load never wedges the id. + // Reserve this id's load slot before the await. loadingIds.add(sessionId) try { - // Validate the PERSISTED cwd BEFORE resuming — `list()` is a - // metadata-only read (no full-log parse), so this rejects a session we - // can't honor WITHOUT ever constructing/registering an agent (a - // post-resume reject would leak the registered agent — cancel() does not - // unregister it — and wedge the id against re-load). The session's bash - // workdir is derived from its persisted `header.cwd` and the request - // `cwd` does NOT override it (resume takes no cwd), so a session with no - // absolute persisted cwd would silently run bash in the SERVER's launch - // dir, not the client's workspace. A session created by this bridge - // always has a cwd (session/new requires it); reject the rest loudly. - // (An id unknown to `list()` falls through to resume, which rejects with - // the backend's not-found error.) + // Validate the persisted cwd before resuming — `list()` is a metadata-only read (no + // full-log parse), so this rejects a session we can't honor WITHOUT ever + // constructing/registering an agent (a post-resume reject would leak the registered + // agent — cancel() does not unregister it — and wedge the id against re-load). const meta = (await sessionPersistence.list()).find(m => m.id === sessionId) if (meta !== undefined) { const persistedCwd = meta.cwd @@ -825,12 +673,8 @@ export function apply(ctx: Context, config: AcpConfig): void { resumeSessionId: sessionId, agentOptions: agentOptions(config), }) - // The bridge may have torn down (disposal / client disconnect) while - // resume() was pending. Its listeners are gone, so installing a record - // now would resurrect a live agent the bridge can no longer drive. Bail — - // and tear down the just-resumed agent (unregister + stop + remove its - // session) before throwing, so it does not leak: it has no SessionRecord, - // so quiesce() would never see it. + // The bridge may have torn down (disposal / client disconnect) while resume() was + // pending. /* v8 ignore next 4 -- the in-memory test transport rejects the in-flight session/load request the instant it closes (before this post-await code runs), so the guard can't be hit in tests; it protects the real @@ -855,19 +699,7 @@ export function apply(ctx: Context, config: AcpConfig): void { pendingSwitches: {}, } sessions.set(sessionId, record) - // Replay the persisted event log to the client as session/update. Use - // the raw event log (NOT deriveMessages, which drops assistant/chunk - // and trace events): RFC 010's load contract reconstructs the streamed - // turns — user prompts (user/message → user_message_chunk), assistant - // text and reasoning (assistant/chunk), and tool calls/results. - // - // Replay through a THROWAWAY presenter, NOT `record.presenter`: a - // historical turn that was interrupted mid-tool (a `tool/call` with no - // matching `tool/result` in the persisted log) would otherwise leave a - // stale in-flight entry on the live presenter, which then serves all - // future live events for this session. The throwaway pairs call→result - // as the log replays in order (same as live) and is discarded after, - // so the record's presenter starts clean for the post-load live stream. + // Replay the persisted event log to the client as session/update. const replayPresenter = makePresenter(agent) const replayTerminal: TerminalRendering = { enabled: terminalEnabled, @@ -899,13 +731,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // waiting for a settle that never comes. throw invalidParams('empty prompt') } - // Install the in-flight slot BEFORE send() (send does not synchronously - // flip status to running; the session/event listener records the turn - // number and settle/rejects it). Capture the log length now as the - // watermark: the settle-from-log fallback infers the owning turn/start - // as the first one appended at/after it, surviving a starved live - // capture. A turn that ends in error rejects this promise (the codec - // never produces an error stop reason). + // Install the in-flight slot before send() (send does not synchronously flip status to + // running; the session/event listener records the turn number and settle/rejects it). const stopReason = await new Promise((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length } rec.agent.send([{ type: 'text', text }]) @@ -916,18 +743,7 @@ export function apply(ctx: Context, config: AcpConfig): void { cancel(params: CancelNotification): Promise { const rec = sessions.get(SessionId(params.sessionId)) if (rec === undefined) return Promise.resolve() - // session/cancel maps to the queue-aware agent.cancel(reason): it aborts - // a RUNNING step, clears the queued + steering FIFOs, and drops a - // turn that is about to start (the pre-step window) — so a queued-but- - // not-yet-started prompt never runs, and a prompt accepted right after - // cannot be batched into the cancelled turn. Scoped to THIS session's - // agent — a cancel in one session never touches another's stream or - // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt - // as cancelled directly here: do NOT rely on the resulting turn/end to - // settle it, because cancel() may drop the turn before any turn/end is - // emitted, and removing this direct settle would move the RPC's - // resolution onto the settleFromLog/agent-status path, changing its - // timing. + // Queue-aware cancellation drops pending prompts as well as the active step. rec.agent.cancel('session/cancel') settlePrompt(rec, 'cancelled') return Promise.resolve() @@ -941,17 +757,10 @@ export function apply(ctx: Context, config: AcpConfig): void { if (typeof params.value !== 'string') { throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`) } - // The setters append ONE log-only event on this session's own log — - // the log is the store (the sandbox RFC § Per-session mode switching): execution, the - // prompt section, and the narrator all fold it from there, and a - // resumed session reports the override back through - // configOptionsFor. A switch while a turn is OPEN anchors - // immediately (the next step sees it); an IDLE switch waits in - // pendingSwitches for the next `turn/start` (turn-enclosure: a bare - // between-turns append would be dropped as crash tail on reload). - // Values are validated against the same closed lists the options - // advertised; an id this composition never advertised (or an unknown - // one) rejects. + // The setters append one log-only event on this session's own log — the log is the + // store (the sandbox RFC § Per-session mode switching): execution, the prompt section, + // and the narrator all fold it from there, and a resumed session reports the override + // back through configOptionsFor. switch (params.configId) { case 'sandbox-mode': { const defaultMode = ctx.get('bash')?.sandboxMode @@ -993,11 +802,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- Connection lifecycle -------------------------------------------------- - // The transport stream. Production wires stdio (stdout carries the protocol); - // tests inject an in-memory pipe pair via config.stream to drive the bridge - // without a subprocess. ndJsonStream is the SDK's stdio framing helper. The - // AgentSideConnection constructor synchronously invokes makeAgent (assigning - // the outer `conn`), so `conn` is set before any agent method runs. + // The transport stream. /* v8 ignore next 4 -- production stdio wiring; tests always inject config.stream */ const stream: Stream = config.stream ?? ndJsonStream( Writable.toWeb(process.stdout) as WritableStream, @@ -1007,29 +812,11 @@ export function apply(ctx: Context, config: AcpConfig): void { /** * Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach - * quiescence"): for each session settle any pending prompt `cancelled`, then - * run that session's {@link AgentHandle} `dispose()` — which stops the loop - * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the - * final `turn/end` + `session/flush` are captured while `onAppend` is still - * attached), unregisters the agent, and removes its session from the store. - * The per-session disposes run in parallel. Idempotent — clears the `sessions` - * map first and memoizes, so a second call (close racing dispose) is a no-op. - * Shared by Cordis disposal AND client disconnect (`conn.closed`). - * - * Per-agent disposal closes the former pre-step best-effort window — but via - * the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`, - * which wakes the parked loop, and `isDisposed()` breaks the loop before a - * queued-but-not-yet-running turn can start (a turn cut off mid-flight ends - * with reason `disposed`, not `aborted`). A bare client disconnect (resolves - * `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent - * and NO session-store entry — not an idled-but-still-registered one. When the - * fiber IS disposed (whole-context or an ACP-only HMR - * `acpFiber.dispose()`), this same memoized teardown runs first; the factory's - * register+start+session effects are ALSO bound to the bridge fiber (the - * factory is reached through this bridge's traceable service proxy, so - * `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the - * bridge fiber), so any agent this path did not reach is still reclaimed by - * fiber disposal. + * quiescence"): for each session settle any pending prompt `cancelled`, then run that + * session's {@link AgentHandle} `dispose()` — which stops the loop (sets `disposed`, aborts + * the in-flight step), AWAITS the loop's exit (the final `turn/end` + `session/flush` are + * captured while `onAppend` is still attached), unregisters the agent, and removes its + * session from the store. */ let quiescing: Promise | undefined const quiesce = (): Promise => { @@ -1058,13 +845,9 @@ export function apply(ctx: Context, config: AcpConfig): void { return quiescing } - // Client disconnect: when the ACP transport closes (editor quits, pipe EOF), - // the in-flight turn would otherwise keep running and its `session/update` - // writes would be silently swallowed by `notify()`. Tear the session down so - // a vanished client does not leave an orphaned running agent. `conn.closed` - // rejects/resolves once; contain any teardown throw (nothing else can act on - // it — the connection is already gone). The Cordis disposer below still runs - // on normal shutdown and is idempotent with this. + // Client disconnect: when the ACP transport closes (editor quits, pipe EOF), the in-flight + // turn would otherwise keep running and its `session/update` writes would be silently + // swallowed by `notify()`. /* v8 ignore start -- the .catch arrow is a defensive guard: conn.closed settling rejected or quiesce() throwing on an already-closed connection is not reproducible through the in-memory test transport (it never severs @@ -1092,22 +875,9 @@ export function agentOptions(config: AcpConfig): { model?: string } { } /** - * Validate the `cwd`/`additionalDirectories` contract shared by `session/new` - * and `session/load`: `cwd` must be absolute (a relative path would be ambiguous - * as a workspace root). The persisted-cwd equality check for `session/load` - * happens after the metadata lookup; this validator only enforces request shape: - * - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd` - * (via `agents.create({meta:{cwd}})`) and thus the default bash workdir. - * - `session/load`: the request `cwd` must be absolute AND must match the - * PERSISTED `header.cwd`, which stays authoritative for the bash workdir — - * the request cwd does not override it. - * Any absolute path is accepted (the per-session cwd flows to the bash executor - * — see `dsh-tool-bash`), so the server no longer has to launch in the - * workspace. `additionalDirectories` must still be empty: widening the - * tool/filesystem scope beyond the single cwd is a separate, unimplemented - * concern (a sandbox seam), and silently ignoring extra roots would desync the - * client's filesystem-scope UI. Both request shapes carry `cwd: string` and - * `additionalDirectories?: string[]`, so one validator covers both. + * Validate the `cwd`/`additionalDirectories` contract shared by `session/new` and + * `session/load`: `cwd` must be absolute (a relative path would be ambiguous as a workspace + * root). */ function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void { if (!isAbsolute(params.cwd)) { @@ -1125,38 +895,13 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { } /** - * Translate a single harness {@link SessionEvent} into the `session/update` - * notification(s) it produces, pushing each via `notify`. Shared by live - * streaming (`session/event`) and `session/load` replay so both paths emit an - * identical update stream from the same event log. - * - * - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks - * - `user/message` → `user_message_chunk` during load replay only — so a - * loaded transcript reconstructs the USER side of each turn without echoing - * a live `session/prompt` back to the client - * - `tool/call` → `tool_call` (pending) - * - `tool/result` → `tool_call_update` (completed/failed) - * - * Tool-call presentation (title/kind/rawInput, and the completed-state content) - * is owned by each TOOL via `presentCall`/`presentResult` — the bridge never - * special-cases tool names. `presenter` resolves those from the tool registry - * and remembers each call's `(name, args)` so the completed `tool/result` (which - * carries neither) can find its tool. A {@link nullToolPresenter} gives the - * generic fallback (title = tool name, raw args as input) when no registry is - * available (e.g. pure translator tests). - * - * Other event types (turn/step boundaries, context/message, …) produce - * no client update. + * Translate one session event into zero or more ACP updates. * @param sessionId - the ACP session id stamped on every emitted notification. * @param event - the harness session event to translate. - * @param notify - sink for each produced `session/update` notification; called - * zero or more times per event (best-effort UI feed, never load-bearing). - * @param presenter - resolves tool-owned render intent for tool events; - * defaults to the generic-fallback {@link nullToolPresenter}. - * @param terminal - the connection's terminal-rendering context; defaults to - * disabled (the plain-text console-block fallback). - * @param options - `includeUserMessages` (default `true`): live streaming - * passes `false` so a prompt the client just sent is not echoed back. + * @param notify - best-effort update sink. + * @param presenter - tool render resolver; defaults to generic presentation. + * @param terminal - terminal rendering context; disabled by default. + * @param options - controls replay of user messages. */ export function streamSessionEventUpdate( sessionId: SessionId, @@ -1243,26 +988,11 @@ export interface TerminalRendering { const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } /** - * Resolves tool-owned presentation for a session's tool-call events. A tool - * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a - * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up - * by name in the registry and applies a generic fallback when a tool defines - * neither. The returned view is what {@link streamSessionEventUpdate} switches on. - * - * The `tool/result` session event does NOT carry the tool name or args — so to - * call a tool's `presentResult` (which needs both), the presenter remembers each - * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the - * matching result. The map is bridge-LOCAL (not a change to the event schema or a - * core service): one presenter per live session - * (and a throwaway per `session/load` replay), and each entry is removed when its - * result arrives. In the normal loop a `tool/call` is always followed by a - * `tool/result` (the registry turns even a thrown tool into an isError result), - * so the map holds only currently-in-flight calls. The one exception is a step - * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can - * leave a single stale entry per such call; this is bounded by the session - * lifetime (the whole presenter is dropped on teardown) and never affects - * correctness — a later result for a different callId is unaffected, and the - * stale entry's only cost is one map slot until the session ends. + * Resolves tool-owned presentation for a session's tool-call events. A tool declares + * `presentCall`/`presentResult` (see `dsh-tools`) returning a `card`-tagged {@link + * ToolCallView}/{@link ToolResultView}; this looks them up by name in the registry and applies + * a generic fallback when a tool defines neither. The returned view is what {@link + * streamSessionEventUpdate} switches on. */ export class ToolPresenter { private readonly pending = new Map() @@ -1307,49 +1037,38 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - // No tool-owned presentation: fall back to the tool name as the title, the - // full parsed args as the raw input, and kind `other` (the generic card). - // The kind is never sniffed from the name — the bridge does not special-case - // tool names; a tool that wants a richer kind declares `presentCall`. + // No tool-owned presentation: fall back to the tool name as the title, the full parsed args + // as the raw input, and kind `other` (the generic card). const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } this.pending.set(callId, { name, args, card: view.card }) return view } /** - * Completed-state render intent for a `tool/result`; consumes the remembered - * `(name, args, card)`. - * @param callId - the id of the matching `tool/call`; an unknown or late id - * falls back to the raw content. - * @param content - the result's content blocks (the fallback and fill-in body). - * @param isError - whether the result is an error, forwarded to `presentResult`. - * @param meta - the result's machine-readable meta, forwarded when present. - * @returns the tool-owned view — an orphaned `terminal` result (no terminal - * call side) and a content-less `generic` are normalized — or the raw-content - * generic card when the tool defines no `presentResult` or threw. + * Resolve completed presentation from the remembered tool call. + * @param callId - matching call id; unknown ids use raw content. + * @param content - fallback result content. + * @param isError - result error flag. + * @param meta - optional tool metadata. + * @returns tool-owned view or normalized generic fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) - // No remembered call (unknown/late callId) → nothing to present from; raw content. if (call === undefined) return { card: 'generic', content } let present: ToolResultView | undefined try { present = this.tools.get(call.name, this.agent) ?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} }) } catch (error: unknown) { - // A throwing presentResult must not break streaming/replay: log + fall back. + // Presentation failure falls back without breaking replay or streaming. this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`) present = undefined } if (present === undefined) return { card: 'generic', content } - // Orphan guard: only honor a `terminal` result when the PENDING call was a - // terminal. A result-only terminal with no matching call-side terminal would - // orphan `_meta.terminal_output` to a terminal Zed never made — drop it back - // to the raw content. + // A terminal result requires a terminal call card. if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content } - // A generic result that reformats no content keeps the RAW result content - // (the tool replaced only the title); fill it so the card is never blanked. + // Preserve raw content when a generic presenter changes only metadata. if (present.card === 'generic' && present.content === undefined) return { ...present, content } return present } @@ -1397,24 +1116,14 @@ type AcpToolCallContent = | { type: 'terminal'; terminalId: string } /** - * Relativize a file card's TITLE path against the session workspace cwd, so a - * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the - * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the - * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The - * pure tool presenter can't see the session cwd, so this happens here where the - * bridge knows it. The rewrite is an exact substring replace of the known raw - * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a - * heuristic. A path outside the workspace, or an absent/relative session cwd, is - * left unchanged. + * Relativize a file card's TITLE path against the session workspace cwd, so a card reads `Read + * src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the reference ACP adapter's + * `toDisplayPath`. */ function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // Only relativize a target that stays INSIDE the workspace. `relative` prefixes - // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone - // or `..…`), NOT a bare `..` char prefix, so a sibling like `..cache/x` - // (a real in-workspace name) still relativizes. Never relativize to the empty - // string (rawPath === cwd — a non-file target). + // Relativize only paths contained by the workspace; keep the workspace root absolute. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } @@ -1473,11 +1182,9 @@ function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRe } } case 'terminal': { - // A terminal-rendered call gets a terminal CARD when the client supports it: - // the description renders ABOVE the card, then the terminal block, plus - // `_meta.terminal_info` (the cwd header). Without the capability it is an - // ordinary execute card whose body is the description and whose rawInput is - // the command; the output arrives as text on the result. + // A terminal-rendered call gets a terminal CARD when the client supports it: the + // description renders ABOVE the card, then the terminal block, plus `_meta.terminal_info` + // (the cwd header). const asTerminal = terminal.enabled const description: AcpToolCallContent[] = view.description !== undefined ? [{ type: 'content', content: { type: 'text', text: view.description } }] @@ -1522,16 +1229,7 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi } /** - * Build the `tool_call_update` (completed) `session/update` from a result render - * intent. A `generic` result sends its reformatted content (or the raw result); - * a `terminal` result rides its output/exit on `_meta` when the client is capable - * (the terminal card consumes them and `content` is OMITTED — a - * `tool_call_update.content` REPLACES the call's content collection in Zed, so - * re-sending would clobber the terminal block the call installed) and otherwise - * derives the fenced ```console fallback from `output`. A `diff` result emits its - * `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a - * create), which replace the diff the call installed — so the model-facing result - * text can never clobber it. + * Build the `tool_call_update` (completed) `session/update` from a result render intent. */ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { const status = isError ? 'failed' as const : 'completed' as const @@ -1573,12 +1271,7 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean ...view.title !== undefined ? { title: view.title } : {}, } case 'diff': { - // A result-time diff: emit one `{ type: 'diff' }` content block per entry - // (an applied hunk for an edit/overwrite, or a whole-file diff for a - // create), mirroring the call-side diff arm. `tool_call_update.content` - // REPLACES the call's content in an editor, so this result diff supersedes - // the diff the pending card installed (and keeps the model-facing result - // text from clobbering it). + // Result diff content replaces the pending card's call-side diff. const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) // Relativize the replacement title against the session cwd from the diff // path, exactly as the call-side card does — `tool_call_update.title` diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 037d00f17e..30fb61ec1f 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -36,10 +36,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => { - // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop - // stay up and the transport is still live. A late session/new must hit the - // `closed` guard and reject — NOT create an agent the disposed bridge can no - // longer stream or settle. Verify the world: no agent appeared. + // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop stay up and the + // transport is still live. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -51,14 +49,9 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => { - // The factory (`ctx.agents.create`) is reached through the bridge's - // traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` - // registration binds to the CALLER context — the bridge fiber — not the - // AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload) - // must therefore reclaim the agent's registry entry, even though agents/ - // agent-loop stay up. This pins the fiber-ownership the bridge's teardown - // doc comment relies on; if a refactor rebinds the registration to the - // AgentLoop fiber, the agent would survive bridge dispose and this fails. + // The factory (`ctx.agents.create`) is reached through the bridge's traceable service + // proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` registration binds to the CALLER + // context — the bridge fiber — not the AgentLoop fiber. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -70,10 +63,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => { - // After teardown (here a client disconnect sets `closed`), a late - // `session/new` must NOT create an orphan agent the bridge can no longer - // drive/settle. The transport is gone so the RPC rejects; assert the world: - // no new agent appeared in the registry. + // After teardown (here a client disconnect sets `closed`), a late `session/new` must not + // create an orphan agent the bridge can no longer drive/settle. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -85,10 +76,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { - // The ACP transport closes (editor quits) while a turn runs. The bridge must - // settle the in-flight prompt cancelled and DISPOSE the agent (the session's - // per-agent AgentHandle teardown) rather than leaving an orphaned running — - // or even idled-but-still-registered — agent whose updates are swallowed. + // The ACP transport closes (editor quits) while a turn runs. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -106,14 +94,7 @@ describe('acp bridge — disposal & HMR safety', () => { // The agent's loop has stopped: status `disposed`. expect(agent.status).toBe('disposed') - // Await the bridge teardown to completion WITHOUT tearing down the root - // agents/sessions services (so we can still query them). acpFiber.dispose() - // invokes the SAME memoized quiesce() the disconnect started and awaits its - // promise — which resolves only after every rec.dispose() (loop exit + - // session removal) has finished, closing the whenIdle()/owned.dispose() - // microtask race. The AgentHandle dispose has run: the agent is unregistered - // and its session removed from the store, not merely idled (the old - // behavior). The services live on the root ctx, so they survive this. + // Await bridge quiescence without disposing root agent and session services. await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() @@ -122,9 +103,6 @@ describe('acp bridge — disposal & HMR safety', () => { it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { // conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously. - // They must share one teardown promise: dispose() must NOT return before the - // disconnect teardown's whenIdle() has settled (a `record === undefined`-only - // guard would let the second caller return early mid-teardown). const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -157,14 +135,10 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { - // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, - // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire - // through the still-attached `session.onAppend` → `session/event`), and only - // THEN detach onAppend + remove the session. If the order were inverted - // (detach first), the closing events would never reach persistence. Drive a - // CLEAN turn to completion, dispose JUST the bridge, then re-load the - // persisted log from disk and assert the closing turn/end is on disk — the - // world, not the agent's self-report. + // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, AWAIT its exit (so + // the loop's final `turn/end` + `session/flush` fire through the still-attached + // `session.onAppend` → `session/event`), and only THEN detach onAppend + remove the + // session. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -186,18 +160,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { - // The teardown-order contract only earns its keep when the closing events are - // produced BY the dispose itself. Here the model stream HANGS, so the turn is - // still open when teardown runs: the composite agent effect stops the loop, - // the loop unwinds and appends `turn/end {disposed}` + runs its final - // `session/flush` — all while `onAppend` is still attached (the session - // detach is the LAST disposer in the same effect's LIFO chain) — and only - // THEN is the session detached. If the order were inverted (or the session - // were a racing SIBLING effect), the abort-produced `turn/end` would never - // reach disk and a re-load would instead show crash-recovery's synthetic - // `interrupted` closer. Re-load from disk and assert the REAL `disposed` - // reason landed — proving the loop's own closing event was captured, not a - // recovered substitute. + // The teardown-order contract only earns its keep when the closing events are produced BY + // the dispose itself. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -223,11 +187,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { - // The factory returns a per-agent AgentHandle whose dispose() tears down - // EXACTLY that agent + its session — RFC 011 isolation. Create two agents - // directly through the registry factory (the same path the ACP bridge uses), - // dispose one handle, and assert the other survives, registered and - // queryable, with its session still in the store. + // The factory returns a per-agent AgentHandle whose dispose() tears down EXACTLY that agent + // + its session — RFC 011 isolation. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, @@ -251,14 +212,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { - // The AgentHandle teardown folds session-detach, register, and loop-stop - // into ONE composite effect whose disposers run as a `.then()` chain. The - // register disposer emits `agent/disposed`; if a listener throws and the - // emit is UNCONTAINED, the rejected chain skips the LATER session-detach - // disposer — stranding the session in the store with `onAppend` attached (a - // leak AND a durability hole, since the new design relies on detach - // running). The emit must be contained. Register a throwing listener, drive - // a clean turn, dispose, and assert the session was STILL removed. + // The AgentHandle teardown folds session-detach, register, and loop-stop into one composite + // effect whose disposers run as a `.then()` chain. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ @@ -276,11 +231,10 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { - // The handle's dispose() must memoize: the underlying cordis effect disposer - // is single-shot, so a second dispose() while the first is mid-teardown would - // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the - // first call's await agent.done + final flush finished. Every caller must - // observe the same quiescence boundary. + // The handle's dispose() must memoize: the underlying cordis effect disposer is + // single-shot, so a second dispose() while the first is mid-teardown would otherwise + // resolve IMMEDIATELY (effect epoch already cleared) — before the first call's await + // agent.done + final flush finished. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 143bd7193c..5c34357554 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -19,9 +19,7 @@ describe('acp bridge — demux & config edges', () => { }) it('ignores events from an agent the bridge does not own (strict id demux)', async () => { - // A second agent created directly on the registry (NOT via the bridge) runs - // a turn. Its session/event + agent/status must NOT produce ACP updates and - // must not settle anything — the bridge demuxes strictly by its own id. + // A second agent created directly on the registry (not via the bridge) runs a turn. harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index a24c7aa145..8926d34922 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -1,12 +1,5 @@ /** - * Shared test fixtures for the ACP bridge specs. A plain module (NOT a - * *.spec.ts) so importing it does not re-register a describe block. - * - * `makeBridgeHarness` builds a full in-memory cordis context (llm + session + - * system-prompt + tools + agents + agent-loop + persistence) with the ACP - * bridge wired to an in-memory transport, plus a `ClientSideConnection` on the - * other end — so a test drives the bridge exactly as an editor would, with no - * subprocess and no real stdio. + * Shared test fixtures for the ACP bridge specs. */ import { Context } from 'cordis' @@ -218,13 +211,9 @@ export async function makeBridgeHarness(options: { } ctx.llm.registerAdapter(['mock'], adapter) - // Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the - // agent writes flow to the client's reader and vice versa. (ndJsonStream - // takes (output, input): the agent writes to a2c and reads from c2a; the - // client writes to c2a and reads from a2c.) The client→agent path (c2a) runs - // through a hand-held writer so a test can close it (`closeClientTransport`) - // to simulate the editor disconnecting — closing it EOFs the agent's reader - // and resolves the bridge's `conn.closed`. + // Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow + // to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent + // writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.) const a2c = new TransformStream() const c2a = new TransformStream() const c2aWriter = c2a.writable.getWriter() @@ -253,11 +242,9 @@ export async function makeBridgeHarness(options: { onSessionUpdateError: undefined, client: undefined as unknown as ClientSideConnection, acpFiber: undefined as unknown as BridgeHarness['acpFiber'], - // Close the writable the CLIENT writes to (c2a) — its readable, which the - // agent's ndJsonStream consumes, then EOFs cleanly, so the bridge's - // `conn.closed` resolves and it sees the client disconnect. If the client - // connection holds a writer lock on it, abort the connection's signal path - // instead by closing through the underlying stream. + // Close the writable the CLIENT writes to (c2a) — its readable, which the agent's + // ndJsonStream consumes, then EOFs cleanly, so the bridge's `conn.closed` resolves and it + // sees the client disconnect. closeClientTransport: async () => { await c2aWriter.close() }, dispose: async () => { await ctx.fiber.dispose() }, storageDir: options.storageDir, @@ -281,27 +268,16 @@ export async function makeBridgeHarness(options: { }, }) - // Wire the bridge (agent side) and the client (test side). The test config - // can override `model` (including to undefined): default to 'mock' unless the - // caller explicitly set the key (even to undefined), so a `{ model: undefined }` - // override means "no model at all". + // Wire the bridge (agent side) and the client (test side). const cfg: AcpConfig = { stream: agentStream, ...options.config } if (!(options.config && 'model' in options.config)) cfg.model = 'mock' - // Mount the bridge the way production does: as a cordis PLUGIN (via - // `ctx.plugin` with the real `inject`), NOT `AcpPlugin.apply(ctx, cfg)` - // directly on the root ctx. The plugin fiber is the faithful reproduction — - // the bridge's `apply` runs inside the fiber's injection scope, and its ACP - // handlers later run from the JSON-RPC read loop OUTSIDE that scope, exactly - // as under the example's cordis.yml. (Mounting directly on root made every - // service an ungated property and hid the "cannot get property … without - // inject" failure that bit a real Zed session.) `harness.acpFiber.dispose()` - // tears down JUST the bridge (its listeners + effect) for the HMR test. + // Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the + // real `inject`), not `AcpPlugin.apply(ctx, cfg)` directly on the root ctx. harness.acpFiber = await ctx.plugin({ name: 'acp-test', - // Use the bridge's REAL exported `inject` so this never drifts from the - // plugin's actual dependency list (adding a service to the bridge must not - // require editing the harness — a hardcoded list silently broke when `tools` - // was added). The bridge programs against the interface packages only. + // Use the bridge's real exported `inject` so this never drifts from the plugin's actual + // dependency list (adding a service to the bridge must not require editing the harness — a + // hardcoded list silently broke when `tools` was added). inject: [...AcpPlugin.inject], apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) }, }) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index 2fbc95b3d9..16e32a7051 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -58,12 +58,7 @@ describe('acp bridge — session/load replay', () => { }) it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => { - // A turn with a REAL bash tool call is persisted, then loaded by a fresh - // bridge. The replayed tool_call/tool_call_update must carry the tool's OWN - // presentation — identical to how it streamed live — via a throwaway - // presenter that pairs call→result as the log replays in order. Uses the - // shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real - // implementation over a mock in tests"). + // A turn with a real bash tool call is persisted, then loaded by a fresh bridge. live = await makeBridgeHarness({ storageDir, withBash: true, @@ -95,10 +90,7 @@ describe('acp bridge — session/load replay', () => { }) it('replays a persisted todo/write as a plan sessionUpdate on load', async () => { - // A turn whose model called todo_write persists a todo/write event. A fresh - // bridge loading the session must re-emit the ACP `plan` update from the log - // (the load replay runs every event through streamSessionEventUpdate), so an - // editor reopening the session sees the current plan. + // A turn whose model called todo_write persists a todo/write event. live = await makeBridgeHarness({ storageDir, withTodo: true, @@ -169,11 +161,7 @@ describe('acp bridge — session/load replay', () => { }) it('a load whose resume finishes after a client disconnect leaks no live session', async () => { - // A session/load is mid-resume() when the client transport closes. The load - // must NOT end up with a live registered agent for the connection that is - // already gone. (The bridge's post-await `closed` guard backs this on real - // stdio; here the SDK rejects the in-flight request on close — either way no - // agent survives.) Stall persistence so resume() is pending across the close. + // A session/load is mid-resume() when the client transport closes. live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] }) await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -198,10 +186,8 @@ describe('acp bridge — session/load replay', () => { }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { - // Seed a session on disk whose header.cwd is a DIFFERENT absolute path than - // the server's launch dir. The bridge must LOAD it (per-session cwd is - // honored — the resumed session keeps header.cwd, and bash routes there), no - // longer reject on a mismatch. + // Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's + // launch dir. loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ @@ -237,9 +223,7 @@ describe('acp bridge — session/load replay', () => { }) it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => { - // A legacy / externally-created session log with no header.cwd. The bridge - // must reject the load rather than accept it and let bash silently fall back - // to the server's launch dir (the request cwd does not override the header). + // A legacy / externally-created session log with no header.cwd. loader = await makeBridgeHarness({ storageDir, script: [] }) await loader.ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd diff --git a/packages/ui/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts index 3dcb4c760f..614d3ca481 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -1,17 +1,7 @@ /** - * Property-based protocol-shape tests for the ACP update stream (RFC 001 → - * ADR 0013 precedent). Fuzz arbitrary harness `SessionEvent` sequences through - * the pure `streamSessionEventUpdate` translator and assert the invariants an - * ACP client relies on: - * - * - every emitted update is a legal `SessionUpdate` variant; - * - a `tool_call_update` for a given id is never emitted before a `tool_call` - * for that id (the client must see the pending call before its completion); - * - the translator is a pure function of the event (same event → same updates), - * so live streaming and `session/load` replay produce identical streams. - * - * Pure-function fuzzing (no live loop) keeps these deterministic — a failure is - * a real finding, not timing noise. + * Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013 + * precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure + * `streamSessionEventUpdate` translator and assert the invariants an ACP client relies on. */ import { describe, expect, it } from 'vitest' diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cb3eab3545..6331147d37 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -288,10 +288,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => }) it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => { - // A buggy tool whose display callbacks throw must NOT fail a live turn or a - // session/load replay (docs/defensive-patterns.md "contain callback exceptions at the - // boundary"). The presenter swallows the throw, reports via onError, and - // falls back to the generic presentation. + // A buggy tool whose display callbacks throw must not fail a live turn or a session/load + // replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). const boom: ToolDefinition = { name: 'boom', description: 'b', @@ -621,12 +619,10 @@ describe('diff-card mapping', () => { }) describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => { - // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call - // installs the call-time snippet, then the tool/result carries the tool's - // computed applied-hunk `meta`, which presentResult narrows into a `diff` - // result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses - // the REAL tool (not a stand-in) per the anti-mock convention, mirroring the - // call-side diff test above. + // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the + // call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`, + // which presentResult narrows into a `diff` result card the bridge forwards as `{ type: + // 'diff' }` content blocks. async function fsCtx(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -678,11 +674,9 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo }) it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => { - // A `tool_call_update.title` replaces the card header, so the result-side - // diff must relativize its title exactly as the pending card did — otherwise - // a completed absolute-path edit flips `Edit src/b.ts` back to the raw - // absolute path. The diff/location paths stay absolute (the editor opens the - // real path). Drive the REAL fs edit tool with an absolute in-workspace path. + // A `tool_call_update.title` replaces the card header, so the result-side diff must + // relativize its title exactly as the pending card did — otherwise a completed + // absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' }) @@ -704,11 +698,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo }) it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { - // A synthetic tool whose presentResult yields a `diff` card with no hunks and - // no title — the shipping fs tools never emit this (edit always has a hunk; - // write always falls back to a whole-file diff), so a stand-in is the only way - // to exercise the empty-content AND absent-title branches of the result-side - // diff arm. + // A synthetic empty diff covers branches shipping filesystem tools cannot emit. const emptyDiffTool: ToolDefinition = { name: 'writer', description: 'writes a file', @@ -734,11 +724,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo }) describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { - // The bridge relativizes a file card's TITLE against the session workspace cwd - // (mirroring the reference adapter's toDisplayPath), while leaving locations/ - // diff paths RAW. Drive it with the REAL fs tools so the title/locations come - // from the shipping presentCall, and pass an ABSOLUTE file path (which a real - // editor forwards). The presenter is pure/args-only; the cwd is known only here. + // The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the + // reference adapter's toDisplayPath), while leaving locations/ diff paths RAW. async function fsCtx(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -789,10 +776,8 @@ describe('relative-path display titles (bridge relativizes the title against the }) it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => { - // `/work/proj/..cache/x` is INSIDE the workspace — its relative form - // `..cache/x` begins with the chars `..` but is NOT a parent segment. The - // guard tests for a `..` SEGMENT, so this relativizes (matching the reference - // adapter, which accepts any target under `cwd + sep`). + // `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins + // with the chars `..` but is not a parent segment. const ctx = await fsCtx() const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' }) expect((update as { title: string }).title).toBe('Read ..cache/x.ts') diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index e182d5f2bf..f39fd8a8ef 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -40,9 +40,8 @@ describe('acp bridge — turn outcomes', () => { }) it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => { - // ACP has no "error" stop reason; a failed turn must surface as a rejected - // session/prompt, not a normal end_turn that hides the failure from the - // client. The bridge rejects via the turn/end{error} log record. + // ACP has no "error" stop reason; a failed turn must surface as a rejected session/prompt, + // not a normal end_turn that hides the failure from the client. harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) @@ -80,12 +79,9 @@ describe('acp bridge — turn outcomes', () => { }) it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => { - // Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline - // stand-in, so this verifies the actual presentCall/presentResult the editor - // sees (docs/testing.md "prefer the real implementation over a mock"). - // The mock MODEL still scripts the tool call (no real LLM needed), but the - // tool and executor are real: a real `echo` runs and its real output flows - // back through the bridge. + // Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline stand-in, so this + // verifies the actual presentCall/presentResult the editor sees (docs/testing.md "prefer + // the real implementation over a mock"). harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -124,11 +120,8 @@ describe('acp bridge — turn outcomes', () => { }) it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => { - // Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output` - // capability in initialize. The bridge must then emit the terminal CARD: the - // description content block THEN a terminal content block + `_meta.terminal_info` - // (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the - // result — and OMIT the update's text content (it would clobber the card). + // Drive the real bash tool, and advertise the Zed `_meta.terminal_output` capability in + // initialize. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -164,11 +157,7 @@ describe('acp bridge — turn outcomes', () => { }) it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => { - // The session is created with the capability ON. A SECOND initialize then - // turns it OFF at the connection level — but this session keeps its snapshot, - // so its bash call STILL renders as a terminal card (call + result agree). - // Without the snapshot, the result path would re-read the now-OFF capability - // and either clobber the card (content sent) or be inconsistent with the call. + // The session is created with the capability ON. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -192,10 +181,9 @@ describe('acp bridge — turn outcomes', () => { }) it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => { - // A buggy tool whose presentCall throws must not fail the live turn — the - // bridge's presenter contains the throw (logging via its onError sink) and - // falls back to the generic title=name presentation. Exercises the real - // bridge wiring of the per-session presenter's error sink. + // A buggy tool whose presentCall throws must not fail the live turn — the bridge's + // presenter contains the throw (logging via its onError sink) and falls back to the generic + // title=name presentation. harness = await makeBridgeHarness({ storageDir, script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')], @@ -236,11 +224,9 @@ describe('acp bridge — turn outcomes', () => { }) it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => { - // A peer session/event listener that runs BEFORE the bridge's listener - // throws on turn/end (prepend: true puts it first). cordis emit stops at the - // throw, so the bridge's session/event listener never sees turn/end and - // cannot settle there. The agent/status idle-fallback must reconcile the - // prompt from the log so the RPC settles instead of hanging. + // A peer session/event listener that runs before the bridge's listener throws on turn/end + // (prepend: true puts it first). cordis emit stops at the throw, so the bridge's + // session/event listener never sees turn/end and cannot settle there. harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] }) harness.ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') throw new Error('peer listener boom') @@ -263,13 +249,8 @@ describe('acp bridge — turn outcomes', () => { }) it('log fallback infers the owning turn when turn/START capture is starved', async () => { - // A peer listener throws on turn/START (not turn/end): the bridge never - // captures inflight.turn via the live stream. A throwing turn/start listener - // also FAILS the turn (the throw is recorded as the turn's error). Without - // the watermark inference the fallback would resolve `cancelled` (the bug); - // with it, it infers the owning turn from the log and REJECTS from that - // turn's error turn/end. (The model's own error is never reached — the turn - // failed at start — so the rejection carries the listener's failure.) + // A peer listener throws on turn/START (not turn/end): the bridge never captures + // inflight.turn via the live stream. harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] }) harness.ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') throw new Error('peer listener boom on start') @@ -280,11 +261,8 @@ describe('acp bridge — turn outcomes', () => { }) it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => { - // A plugin injects context (a one-shot injection-triggered turn) right after - // the prompt is queued but before the prompt's own message turn runs. The - // bridge must NOT mistake the injection turn's turn/end for the prompt's — - // it correlates only to message-triggered turns. The prompt settles on its - // OWN turn with the real model answer. + // A plugin injects context (a one-shot injection-triggered turn) right after the prompt is + // queued but before the prompt's own message turn runs. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(AgentId(sessionId))! @@ -332,11 +310,9 @@ describe('acp bridge — turn outcomes', () => { }) it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => { - // Over the async JSON-RPC transport the loop usually wakes before cancel - // arrives, so this is a running/mid-step cancel (the synchronous pre-step - // DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee: - // the prompt settles cancelled, the agent reaches idle, and no second/leaked - // turn runs afterward. + // Over the async JSON-RPC transport the loop usually wakes before cancel arrives, so this + // is a running/mid-step cancel (the synchronous pre-step DROP is unit-tested in + // agent-loop/cancel.spec.ts). harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] }) const sessionId = await newSession(harness) const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -353,10 +329,9 @@ describe('acp bridge — turn outcomes', () => { }) it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => { - // The ACP bridge settles the cancel RPC synchronously and accepts the next - // prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO - // whenIdle() between, the production race. An idle cancel must be a no-op that - // does NOT drop the following prompt. + // The ACP bridge settles the cancel RPC synchronously and accepts the next prompt WITHOUT + // awaiting quiescence — so this drives cancel→prompt with NO whenIdle() between, the + // production race. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) // Cancel while idle (no prompt in flight) — a no-op. @@ -392,10 +367,8 @@ describe('acp bridge — turn outcomes', () => { }) it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => { - // Regression: prompt A runs; cancel settles A and frees the slot; A's - // aborted turn/end is still pending in the loop. Prompt B is sent before - // A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT - // settle B — B owns a later turn. B then completes on its OWN turn/end. + // Regression: prompt A runs; cancel settles A and frees the slot; A's aborted turn/end is + // still pending in the loop. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] }) const sessionId = await newSession(harness) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 8f3392e913..8b52f44367 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,27 +1,7 @@ /** - * Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load - * the gitignored `.env`, install the fail-loud Loader guards, resolve the - * config path (snapshot-aware), and drive the cordis Loader against a leaf - * `cordis.yml` until the whole tree has settled. Each bin stays a thin - * self-executing composition over these helpers, parameterized by its - * diagnostic prefix; the loader-failure lore lives here, once, under the - * per-file coverage gate. - * - * Two failure classes the guards handle: - * - * - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses - * `Promise.allSettled`, which swallows rejections). A plugin whose - * `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()` - * resolves — Node's default handler already exits non-zero, and - * {@link installFailLoud} replaces the noisy dump with one labelled stderr - * line and a guaranteed `exit(1)`. - * - A plugin module that fails to IMPORT is caught and only LOGGED by the - * cordis Loader (`entry._init`), leaving the entry with no `fiber` and - * producing no rejection — the process would otherwise exit 0 with a usable - * config typo reported only as a log line; {@link assertEntriesLoaded} makes - * `boot()` reject on any such entry instead of returning a half-empty - * context. - * + * Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load the gitignored + * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and + * drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled. * @module @deepseek-ai/dsh-app-boot */ @@ -31,13 +11,11 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' /** - * Resolve the config to boot, honoring snapshot REPLAY. Given the requested - * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in - * the SAME directory (the keyless replay tree). Other modes — including no - * snapshot mode at all — use the path as-is. Returns an absolute path resolved - * from `cwd`. + * Resolve the config to boot, honoring snapshot replay. + * * @param configPath - the requested config path (absolute, or relative to `cwd`). - * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename. + * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the + * basename. * @param cwd - the base a relative `configPath` resolves against. * @returns the absolute path of the config to boot. */ @@ -88,15 +66,8 @@ export interface FailLoudProcess { } /** - * Make a load failure fail loud with a clear message on stderr. Covers the - * failure path {@link assertEntriesLoaded} cannot: an include whose - * `[Service.init]` throws (e.g. a config FILE that does not exist in a real - * directory) surfaces as an unhandled promise rejection AFTER `boot()` - * resolves. Node's default handler already exits non-zero on an unhandled - * rejection; this replaces the noisy stack dump with a single labelled line on - * STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and - * guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller - * (tests use it; the bins run until exit and never do). + * Make a load failure fail loud with a clear message on stderr. + * * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @returns the uninstaller that removes the rejection handler. @@ -111,13 +82,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process } /** - * After the tree settles, assert every loader entry actually started. A - * started entry has a `fiber`; an entry with `fiber === undefined` after the - * tree settled never loaded (its module failed to import), so throw and let - * `boot()` reject instead of returning a half-empty context. A `disabled` - * entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately - * skips `init()` for it — a valid "plugin turned off" config, not a failed - * import — so it is excluded. + * After the tree settles, assert every loader entry actually started. + * * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. */ @@ -130,27 +96,9 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } /** - * Boot the Loader against `absoluteConfigPath` and return the root context - * once the whole tree has settled. The include is handed the config's ABSOLUTE - * `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl` - * (an absolute URL ignores the base) and can never fall back to the cwd; - * `baseUrl` is still pinned to the config's directory so the config's OWN - * relative plugin/include paths resolve against it. + * Boot the Loader against `absoluteConfigPath` and return the root context once the whole tree + * has settled. * - * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns - * once the include ENTRY is registered, but the include then loads its child - * plugins asynchronously — without awaiting the tree, `boot()` would resolve - * while the app's plugins are still mounting, and a CLI process with no - * attached handles yet exits 0 silently. Failures surface two ways: an entry - * whose module failed to import is caught here by {@link assertEntriesLoaded} - * (this `boot()` rejects); an init that THROWS surfaces as an unhandled - * rejection caught by {@link installFailLoud} (installed by the bin first). - * - * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) - * are resolved by the cordis Loader's internal module loader, which is only - * active under `node --expose-internals`; a consumer running a built bin must - * pass that flag (or install the plugins where node hoists them). Relative - * specifiers resolve against the config directory with no flag. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index 7056486996..c16863b8cc 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -1,14 +1,7 @@ #!/usr/bin/env node /** - * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that - * loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM - * adapter and a bash executor). The boot glue — `.env` loading, the fail-loud - * Loader guards, the settle-the-tree boot sequence — lives in - * {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin. - * - * Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The - * `demo:echo` / `demo:repl` scripts invoke it with the example's config. - * + * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that loads the {@link + * @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM adapter and a bash executor). * @module @deepseek-ai/dsh-stdio-agent/bin */ diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 1037c28112..9657298f09 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,40 +1,8 @@ /** - * The stdio chat app: the default agent spine ({@link - * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal - * chat needs — a console logger, the readline UI (the in-package `stdio-chat` - * module), JSONL session - * persistence, and a pre-created `main` agent the UI drives. - * - * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the - * console (stdout is just the terminal) and always pre-creates the `main` agent - * the readline UI sends to. The leaf supplies the swappable backends (the LLM - * adapter, the bash executor), optional product tools, the optional `hmr` - * dev-reload plugin, and this app's {@link Config} (model, prompt, persistence - * root, welcome banner). - * - * `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only, - * subprocess-only dev plugin (its constructor throws without `--expose-internals` - * + a live `loader`, and the in-process test tier cannot even import it), so a - * package whose `apply` statically pulled it in could never be unit-tested or - * carry the per-file coverage gate. Unlike the console logger, a stray `hmr` is - * not a stdout-purity footgun — so leaving it at the leaf costs no safety, while - * baking the LOGGER in (the real coupling) keeps stdout-vs-no-stdout a property - * of the artifact. - * - * Counterpart to {@link @deepseek-ai/dsh-acp-agent}, which bakes in the OPPOSITE - * cluster (no stdout logger, no pre-created agents — the ACP bridge reserves - * stdout for JSON-RPC and creates agents on demand). Splitting the two front - * doors into two packages makes each cluster a property of the artifact: there - * is no logger entry in the ACP leaf to get wrong. - * - * 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` and drop the `Config` - * namespace (see docs/postmortem/0001). This app carries no `inject`, so a - * collapsed shape would BOOT rather than crash a smoke — the shape is pinned by - * the explicit `unwrapExports` assertion in this package's unit suite, and the - * keyless echo smoke proves the composed tree runs through the real Loader. - * + * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the + * coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the + * in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent + * the UI drives. * @module @deepseek-ai/dsh-stdio-agent */ diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 4c37f40d37..01ffdb6a04 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -1,17 +1,6 @@ /** - * The stdio app's readline UI: reads lines from stdin → `agent.send()`/ - * `steer()`, and renders the durable transcript to stdout. A UI is "just a - * plugin" — it consumes the `session/event` feed (the assistant token stream, - * turn/step boundaries, tool activity, todos) plus a few `agent/*` control - * events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` - * service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle - * exit handling, configured via {@link Config}. - * - * An internal module of the stdio app, not a package of its own: the app's - * front-door cluster always includes this UI, and nothing else composes it. - * The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin - * contract the app's `ctx.plugin(uiStdio, …)` mount consumes. - * + * The stdio app's readline UI: reads lines from stdin → `agent.send()`/ `steer()`, and renders + * the durable transcript to stdout. * @module @deepseek-ai/dsh-stdio-agent/stdio-chat */ @@ -80,15 +69,10 @@ type OptionSelection = | { kind: 'invalid' } /** - * The plugin body, parameterized over its I/O runtime. `apply` is the thin - * production wrapper that binds the real `process` streams; tests call this - * directly with fakes. Returns nothing — all registration is via `ctx.on`/ - * `ctx.effect`, so fiber disposal tears every listener and the readline - * interface down. - * @param ctx - the context supplying the `agents` service and the event feeds. - * @param config - the plugin config; defaults are re-applied here for direct - * callers that bypass Loader validation. - * @param runtime - the process-I/O seam (line source, render sink, exit hook). + * Register stdio chat against an injectable I/O runtime. + * @param ctx - agent and event context. + * @param config - plugin config, defaulted for direct callers. + * @param runtime - line source, render sink, and exit hook. */ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { // Default here too (not just via schemastery's `.default()`): this helper is @@ -99,26 +83,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime - // Render label lookup: the `turn/start` session event carries only the turn - // number, so to print the short agent id (`[main turn 1]`) we map the - // session's id to its agent's id. The session id is not reliably the agent id - // (a session can be created with an explicit/client-supplied id), so build the - // map from `agent/created` rather than parsing the id string. Seed from the - // registry's current agents first: an agent registered before this plugin - // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR - // reload of just this fiber) already fired its `agent/created`, so the live - // listener alone would miss it and its turns would fall back to the raw - // session id. + // Render label lookup: the `turn/start` session event carries only the turn number, so to + // print the short agent id (`[main turn 1]`) we map the session's id to its agent's id. const labelBySession = new Map() for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) - // Transcript rendering off the durable `session/event` feed — the assistant - // token stream, turn/step boundaries, tool activity, and todos all come from - // the one canonical stream (no agent/* mirrors). A single listener over the - // append order keeps `inReasoning` transitions deterministic across chunk and - // boundary events. + // Transcript rendering off the durable `session/event` feed — the assistant token stream, + // turn/step boundaries, tool activity, and todos all come from the one canonical stream (no + // agent/* mirrors). let inReasoning = false ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { @@ -161,16 +135,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt ctx.effect(() => { const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - // Piped-input exit, once stdin reaches EOF: - // - If no line ever submitted work (empty stdin, blank-only lines), exit - // immediately — no turn will ever start, so there is nothing to wait - // for. (Gating on an observed 'running' here would hang forever.) - // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Two subtleties this handles: the loop batches - // several queued messages into ONE turn (one idle), so we don't count - // sends; and agent.send() does NOT synchronously flip status to - // 'running', so requiring an observed 'running' first (`sawRunning`) - // avoids exiting in the gap before the turn starts and dropping work. + // Piped-input exit, once stdin reaches EOF: - If no line ever submitted work (empty stdin, + // blank-only lines), exit immediately — no turn will ever start, so there is nothing to + // wait for. let stdinClosed = false let disposed = false let submittedWork = false @@ -188,10 +155,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agent = ctx.agents.get(agentId) if (agent && agent.status !== 'idle') return // a turn is still running } - // Let any final output flush, then exit. The handle is tracked so the - // disposer can cancel it — a dispose within the flush window must not let - // the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g. - // repeated idle signals) coalesce onto the one pending timer. + // Let any final output flush, then exit. if (exitTimer !== undefined) { return // exit already scheduled — coalesce re-entrant calls } diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 31cdf3eb08..c943f9b8ac 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -7,31 +7,13 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' /** - * BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes - * boot `src/bin.ts` under tsx — but the package's `bin` field points at - * `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure - * modes the built bin had: (1) `boot()` returned before the loader tree settled, - * so the process exited 0 with no output and load errors surfaced as unhandled - * rejections AFTER boot; (2) config-path resolution could fall back to the cwd. - * This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the - * banner + echo round-trip, so a regression in the published entry fails here. - * - * It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`) - * the test SKIPS with a note. CI runs it after the build step. Setup mirrors a - * real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored - * `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml` - * that loads the app + the example's mock backend, and `node --expose-internals` - * (the cordis Loader resolves bare plugin specifiers via its internal module - * loader, active only under that flag — the same flag `demo:echo` passes). + * Built-ARTIFACT smoke for the published `dsh-stdio-agent` bin. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') -// Workspace packages the stdio app's tree needs, by repo-relative path. Each is -// symlinked into the temp consumer's node_modules under its package name, so -// plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml -// to the built `lib/` (package.json `main`), exactly as an installed dep would. +// Workspace packages the stdio app's tree needs, by repo-relative path. const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', @@ -153,9 +135,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { - // A `disabled: true` entry settles without a fiber by design; the fail-loud - // entry-load guard must NOT mistake it for a failed import. Even though its - // plugin path does not exist, the app boots and the round-trip works. + // A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load + // guard must not mistake it for a failed import. consumer = await makeConsumer('DISABLED-OK ready.', true) const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') expect(stderr).not.toContain('failed to load') @@ -165,10 +146,7 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A consumer who typos the config path must get a clear failure, not silent - // success. This dir does not exist, so the include PLUGIN itself fails to - // import; the cordis Loader logs that and leaves the entry with no fiber (no - // rejection), which `boot()`'s entry-load check turns into a thrown error. + // A consumer who typos the config path must get a clear failure, not silent success. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') expect(code).not.toBe(0) @@ -176,9 +154,7 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { - // The config DIRECTORY exists (the include plugin imports), but the file does - // not — the include's init throws "config file not found", which surfaces as - // an unhandled rejection the fail-loud guard turns into a non-zero exit. + // Existing directory plus missing config exercises the include plugin's fail-loud path. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '') expect(code).not.toBe(0) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 0668d25fb0..af5c640a28 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -10,20 +10,9 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' /** - * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it - * composes the console logger, the agent-core spine (pre-creating the `main` - * agent from the app config), the JSONL backend, and the readline UI in one - * `ctx.plugin`. The forwarded `model` reaches the pre-created agent and - * `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/ - * `resumeSessionId` route to their backends. - * - * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev - * plugin the in-process tier cannot import); the keyless echo smoke in - * `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots - * through the real Loader, while the export SHAPE is pinned by this suite's - * explicit `unwrapExports` assertion (an inject-less app would boot past a - * stray default rather than crash). Here we assert the composition + config - * forwarding the unit tier can reach. + * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it composes the + * console logger, the agent-core spine (pre-creating the `main` agent from the app config), + * the JSONL backend, and the readline UI in one `ctx.plugin`. */ async function mount(config: stdioAgent.Config): Promise { const ctx = new Context() @@ -163,14 +152,7 @@ describe('dsh-stdio-agent app', () => { }) 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, so that collapse would NOT crash at load (the keyless - // echo smoke would still boot the tree) — it would silently lose its config - // schema. So guard the shape directly here: assert no `default` export, and - // that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding - // `export default` to src/index.ts fails this test. + // Loader must retain the namespace so name, Config, and apply survive unwrapping. expect('default' in stdioAgent).toBe(false) expect(typeof stdioAgent.apply).toBe('function') diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 93683d0768..03deab34ac 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -179,11 +179,9 @@ describe('createStdioChat rendering', () => { }) it('seeds labels for agents already registered before the UI installs', async () => { - // The pre-created `main` agent (and any agent surviving an HMR reload of just - // this fiber) fired its `agent/created` before the UI's listener existed, so - // the live listener alone would miss it. Seeding from `ctx.agents.list()` at - // install time is what keeps its turn header showing `[main turn N]` instead - // of the raw session id. + // The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber) + // fired its `agent/created` before the UI's listener existed, so the live listener alone + // would miss it. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 4fb50ba23b..d2013cb51c 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -1,35 +1,9 @@ /** - * Approval seam: `ctx.approval` answers exactly one question — "may this - * specific action proceed?" — by dispatching the `approval/request` waterfall - * to whatever answerers the deployment composed (an ACP editor prompt, an - * auto-decide policy, a scripted test listener) and returning a closed - * {@link ApprovalOutcome}. With no answerer the waterfall falls through to the - * built-in default `'unavailable'`: absence of a UI can never grant anything. - * - * The service is the MECHANISM (dispatch, cancellation, audit); answerers are - * the POLICY. It serves both ask paths the sandbox RFC names — the - * `tools/pre-execute` `ask` decision and the sandbox post-denial escalation — - * so every asker shares one outcome - * vocabulary and one audit trail. Grants are one-shot by design: an - * `'allowed-once'` outcome authorizes the single action it was asked about, - * never a class of future actions. - * - * Every request lands two log-only session events on the requesting agent's - * log (`approval/asked` / `approval/decided`, paired by - * {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the - * model-visible transcript: the model only ever sees the tool result the - * caller derives from the outcome. - * - * The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching): - * `effective = fold(the session's 'approval/policy' events, last one wins) - * ?? config.policy` — the session log is the store, so an override survives - * restart by replay. The service resolves `'never'` sessions to - * `'rejected'` inside `request()` before dispatching any answerer (no - * registration order, including a later `prepend`, can precede it); a prompt section states `'never'` - * (and only `'never'` — an availability promise is unknowable without - * asking); an `agent/pre-step` narrator explains a switch to the model in at - * most one coalesced notice per step. - * + * Approval seam: `ctx.approval` answers exactly one question — "may this specific action + * proceed?" — by dispatching the `approval/request` waterfall to whatever answerers the + * deployment composed (an ACP editor prompt, an auto-decide policy, a scripted test listener) + * and returning a closed {@link ApprovalOutcome}. + * Scope-filtered dispatch: keyed to `req.agent`. * @module @deepseek-ai/dsh-user-approval */ @@ -52,20 +26,7 @@ declare module 'cordis' { interface Events { /** * Waterfall asking the composed answerers to decide one approval request. - * Dispatched only from {@link 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. + * * @param req - the accepted decision (agent, tool identity, reason, signal). * @mode waterfall */ @@ -276,18 +237,9 @@ export interface Config { } /** - * The `ctx.approval` service: dispatches {@link ApprovalRequest}s 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. + * The `ctx.approval` service: dispatches {@link ApprovalRequest}s 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. */ export class ApprovalService extends Service { static Config: z = z.object({ @@ -299,12 +251,10 @@ export class ApprovalService extends Service { const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent) - // Visibility layer 1, scoped on the prompt registry so headless - // compositions mount the seam without it: state the one deterministic - // policy per session. 'ask' renders only a source-owned state marker — - // stating "you will be asked" would overclaim in a composition with no - // answerer. The marker, not deployment-controlled prose, is what the - // restart narrator reads back from the logged request header. + // Visibility layer 1, scoped on the prompt registry so headless compositions mount the seam + // without it: state the one deterministic policy per session. 'ask' renders only a + // source-owned state marker — stating "you will be asked" would overclaim in a composition + // with no answerer. ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.section({ name: 'approval:policy', @@ -319,16 +269,10 @@ export class ApprovalService extends Service { }) }) - // Visibility layer 2: the boundary narrator. pre-step runs after prompt - // assembly but before the request history is derived, so the notice is - // seen by THIS step's request: idle-time flip-flops coalesce at the - // turn's first step (net-zero → nothing), and a mid-turn switch is - // narrated no later than the next step. What each session was last told - // is in-memory with a log-derived fallback (the folded header's system - // text), so restarts lose nothing. Attribution is positional: an - // override event after the log's last `request/header*` was a runtime - // switch by the user; otherwise the configured default moved under the - // session (operator/config). + // Visibility layer 2: the boundary narrator. pre-step runs after prompt assembly but before + // the request history is derived, so the notice is seen by this step's request: idle-time + // flip-flops coalesce at the turn's first step (net-zero → nothing), and a mid-turn switch + // is narrated no later than the next step. const narrated = new WeakMap() ctx.on('agent/pre-step', (agent) => { const session = agent.session @@ -361,30 +305,13 @@ export class ApprovalService extends Service { } /** - * Ask the composed answerers to decide one request. Requires an open turn - * on the requesting agent's session — the audit pair below is turn-enclosed - * by contract (the turn is the log's commit/replay boundary; an idle append - * would be dropped as crash tail on reload) — and throws before appending - * anything when called idle; asking outside a turn is a deferred design. - * Within that precondition it always resolves to an outcome, never rejects: - * an aborted signal yields `'cancelled'`, a missing or throwing answerer - * yields `'unavailable'` (fail closed), and a rogue non-vocabulary return - * value is normalized to `'unavailable'`. The caller-owned request is - * synchronously snapshotted, so later mutation cannot split routing, - * dispatch payload, cancellation, or the audit pair across agents/sessions. - * Appends the - * `approval/asked`/`approval/decided` audit pair (log-only) around the - * decision regardless of outcome. A synchronous session observer failure - * after an audit event entered the append-only log is contained; the event - * is already authoritative, so the pair still completes and the request - * still resolves. + * Ask the composed answerers to decide one request. + * * @param req - the pending decision (agent, tool identity, reason, signal). * @returns the closed outcome; `'allowed-once'` is the only grant. */ async request(req: ApprovalRequest): Promise { - // Accept one immutable request shape before the first async boundary. The - // caller retains its record and may mutate it as soon as this async method - // returns; identity capabilities stay live, but the record is never reread. + // Accept one immutable request shape before the first async boundary. const agent = req.agent const toolName = req.toolName const callId = req.callId @@ -422,11 +349,9 @@ export class ApprovalService extends Service { } /** - * Append one audit event while distinguishing a post-append observer throw - * from a failure that prevented the event entering the log. `Session.append` - * pushes first and then notifies synchronously, so log growth proves the - * event is already authoritative; that observer failure is reported and - * contained so it cannot reject the approval or suppress its matching event. + * Append one audit event while distinguishing a post-append observer throw from a failure + * that prevented the event entering the log. + * * @param session - the captured session receiving both audit events. * @param type - the audit event currently being appended. * @param id - the request id, used to identify the contained failure. @@ -461,11 +386,7 @@ export class ApprovalService extends Service { /** Dispatch the waterfall, contained and raced against the accepted signal. */ private async decide(req: Readonly): Promise { if (req.signal?.aborted) return 'cancelled' - // The 'never' policy is decided HERE, before any dispatch: a listener - // registered with `prepend: true` after this service mounts would sit - // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the - // documented promise that 'never' rejects deterministically regardless - // of registration order — only the service's own request path can. + // Enforce never before dispatch so listener order cannot bypass it. if (this.effectivePolicy(req.agent) === 'never') return 'rejected' // Enter the promise chain BEFORE dispatching: a listener that throws // SYNCHRONOUSLY (before its first await) must land in the same rejection diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 0e69b8dfd7..fa7257b4d3 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -460,10 +460,9 @@ describe('approval policy (the approval/policy fold)', () => { }) it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => { - // Cordis prepend unshifts ahead of every existing listener, including - // any gate LISTENER the service could register — which is exactly why - // the 'never' decision lives inside request() instead. The eager grant - // below must never be consulted. + // Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the + // service could register — which is exactly why the 'never' decision lives inside request() + // instead. const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 051ced94b7..6f2af0de07 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -1,23 +1,6 @@ /** - * The `Branded` nominal-typing primitive — a type-only utility (no runtime - * code, no harness-package dependency) shared by every package that owns a - * cross-boundary id. - * - * A brand makes structurally-identical strings non-interchangeable at the type - * level: an `AgentId` cannot be passed where a `CallId` is expected, even - * though both are plain strings at runtime. Construction goes through a per-id - * factory in the OWNING package (a plain cast inside — zero runtime cost); - * comparison, logging, and serialization all behave as ordinary strings. - * - * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent, - * `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package - * boundaries and could plausibly be confused; not every string needs a brand. - * This package owns ONLY the primitive — no concrete id, no runtime code beyond - * the (erased) type — so the brand vocabulary stays dependency-free and a - * package can brand its ids without depending on an unrelated capability - * package (e.g. dsh-bash brands its ids without pulling in dsh-llm). - * + * The `Branded` nominal-typing primitive — a type-only utility (no runtime code, no + * harness-package dependency) shared by every package that owns a cross-boundary id. * @module @deepseek-ai/dsh-brand */ diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index ed95a877d3..e4e54c60fc 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -1,30 +1,7 @@ /** - * The timing-and-classification half of a timeout — a zero-dependency library - * of pure functions shared by every capability that clamps a caller's timeout - * hint, arms a deadline, and later has to tell "timed out" apart from - * "cancelled". It owns NO termination: the returned {@link deadline} signal only - * NOTIFIES; actually stopping the work (SIGKILL a process group, tear down a - * fetch socket, …) stays in each capability's implementation, because that - * mechanism differs per capability and no shared layer can own all of them. - * - * This is deliberately a library, not a cordis service or plugin: it takes no - * `ctx`, registers nothing, holds no cross-call state, and emits no events. A - * "timeout service" would have to understand how to stop every capability's - * work — exactly the knowledge a microkernel keeps out of shared layers. - * - * The four exports and their division of labor: - * - {@link clampTimeout} — validate a caller's optional positive hint, fill the - * backend default, cap at the backend max (pure arithmetic + the shared - * positive-finite request contract). - * - {@link deadline} — fuse upstream cancellation with a timeout into one - * `AbortSignal`, the timeout carrying an identifiable {@link TimeoutReason}; - * `[Symbol.dispose]` clears the timer. - * - {@link timeoutOf} — classify an aborted signal (or error): a - * {@link TimeoutReason} means the timeout fired, anything else (or nothing) - * means it did not. - * - {@link TimeoutReason} — the internal classification reason; providers - * translate it into their own public error/result shape before returning. - * + * The timing-and-classification half of a timeout — a zero-dependency library of pure + * functions shared by every capability that clamps a caller's timeout hint, arms a deadline, + * and later has to tell "timed out" apart from "cancelled". * @module @deepseek-ai/dsh-timeout */ @@ -52,17 +29,15 @@ export class TimeoutReason extends Error { } /** - * Validate a caller's optional timeout hint, fill it from the backend default, - * then cap at the backend max. The shared positive-finite request contract: - * a supplied `requested` must be a positive finite number or this throws — - * `0` is NOT a caller-facing "disable timeout" value (that sentinel is internal - * to {@link deadline}). A missing `requested` falls back to `def`. + * Validate a caller's optional timeout hint, fill it from the backend default, then cap at + * the backend max. * * @param requested The caller's optional hint; validated when present. * @param def The backend default applied when `requested` is absent. * @param max The backend upper bound the result is capped to. - * @param name Field name used in the thrown message (so the caller sees which input was bad). - * @returns The effective timeout in milliseconds: `min(requested ?? def, max)`. + * @param name Field name used in the thrown message (so the caller sees which input was + * bad). + * @returns The effective timeout in milliseconds: `min(requested ?? */ export function clampTimeout( requested: number | undefined, @@ -85,23 +60,9 @@ export interface Deadline { } /** - * Build a deadline signal that aborts on upstream cancellation OR on timeout, - * with the timeout carrying an identifiable {@link TimeoutReason} (unlike - * native `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). It is - * `AbortSignal.any([upstream, ])` — the single primitive that fuses - * two abort sources — with the reason and a disposable timer added on top. - * - * `timeoutMs <= 0` is the INTERNAL "no timeout" sentinel for backend-owned - * background work: arm no timer and forward only the upstream signal; with no - * upstream either, return a never-aborting signal so callers keep one call - * shape. External request hints validate as positive finite via - * {@link clampTimeout} before reaching here, so `0` never arrives from a model - * or plugin. - * - * The returned object's `[Symbol.dispose]` clears the timer — use `using` for a - * scope-lifetime consumer, or call it manually for an event-lifetime one. The - * signal only NOTIFIES; the caller must attach its own termination (kill the - * process group, abort the fetch, …). + * Build a deadline signal that aborts on upstream cancellation OR on timeout, with the + * timeout carrying an identifiable {@link TimeoutReason} (unlike native + * `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). * * @param upstream The caller's cancellation signal, if any, fused into the result. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer). @@ -114,9 +75,8 @@ export function deadline( code: string, ): Deadline { if (timeoutMs <= 0) { - // No timeout (background work): forward only the upstream signal, or a - // never-aborting one when there is no upstream. No timer, so dispose is a - // no-op — the empty method keeps the one call shape for every caller. + // No timeout (background work): forward only the upstream signal, or a never-aborting one + // when there is no upstream. return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} } } @@ -132,22 +92,8 @@ export function deadline( } /** - * Recover the {@link TimeoutReason} from an aborted signal (or any object with a - * `reason`), else `undefined`. This is the classification half: a provider - * calls it on the deadline signal after an abort to decide whether the cause - * was its timeout (translate to the capability's timeout error/field) or an - * ordinary upstream cancellation (`undefined` → the cancel path). - * - * Pass `code` to scope the match to THIS deadline's timer. It matters under - * nesting: when the `upstream` handed to {@link deadline} is itself a deadline - * signal (e.g. a future `tools/execute` middleware arming a per-call deadline), - * `AbortSignal.any` preserves the OUTER `TimeoutReason` if the outer timer fires - * first. Without `code`, the inner capability would misclassify that outer - * timeout as its own (`timedOut:true` / `WEB_FETCH_TIMEOUT`) though its local - * timer never expired; with `code`, a foreign timeout reads as `undefined` and - * falls through to the upstream-cancel path, which is the correct classification - * from the inner capability's view. Omit `code` only to ask "was this ANY - * timeout" (a generic middleware that owns no single code). + * Recover the {@link TimeoutReason} from an aborted signal (or any object with a `reason`), + * else `undefined`. * * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error). * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches. diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index 588317f48d..dba7e58fb4 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -171,10 +171,8 @@ describe('timeoutOf', () => { describe('deadline — nested deadlines', () => { it("does not misclassify an outer deadline's timeout as the inner code", () => { - // The upstream handed to the inner deadline is ITSELF a deadline that has - // already timed out (outer). AbortSignal.any preserves the outer reason; - // scoping timeoutOf to the inner code keeps the inner capability from - // reporting the outer timeout as its own — it reads as an upstream cancel. + // The upstream handed to the inner deadline is ITSELF a deadline that has already timed out + // (outer). const outer = new AbortController() outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30)) using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT') diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 571ce00797..f5b823f5aa 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -1,15 +1,8 @@ /** - * The model-facing `web_fetch` tool: retrieve the content of a specific URL. - * Execution goes through `ctx.web` — this module owns the model-facing schema, - * argument validation, and PRESENTATION (HTML→markdown, truncation formatting), - * while the fetch provider owns safe retrieval (transport, redirects, caps). - * - * The model-facing schema exposes NO timeout knob: the tool-call budget is - * deployment policy DECLARED via this package's `fetchTimeoutMs` config (attached - * as `ToolDefinition.timeoutMs`) and ENFORCED by `@deepseek-ai/dsh-timeout-policy` - * (a `tools/execute` wrapper), matching the reference-agent `WebFetch` shape. This - * tool just forwards the (possibly deadline-derived) `exec.signal` to `ctx.web`; - * the provider keeps its own timeout only as a resource backstop for direct callers. + * The model-facing `web_fetch` tool: retrieve the content of a specific URL. Execution goes + * through `ctx.web` — this module owns the model-facing schema, argument validation, and + * PRESENTATION (HTML→markdown, truncation formatting), while the fetch provider owns safe + * retrieval (transport, redirects, caps). */ import type { Context } from 'cordis' diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts index d848ad6225..de4e3e3cf7 100644 --- a/packages/web/tool-web/src/html.ts +++ b/packages/web/tool-web/src/html.ts @@ -1,11 +1,5 @@ /** - * Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch` - * presentation. This is intentionally NOT a full HTML parser: it strips - * script/style/noscript, drops tags, decodes the common named/numeric entities, - * and collapses whitespace into a readable plain-text approximation with a few - * markdown affordances (headings, list bullets, links). A heavier converter can - * replace this without touching the seam or the tool schema. - * + * Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch` presentation. * @module @deepseek-ai/dsh-tool-web/html */ diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 0f948eacb6..7867a33d57 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -1,19 +1,7 @@ /** - * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` - * seam. This root plugin registers the tools the product has ENABLED, composing - * the per-tool registration helpers (`applyWebSearchTool`, `applyWebFetchTool`). - * - * The package owns model-facing concerns only — tool names, JSON schemas, - * argument validation, prompt sections, result-cap constants, result formatting, - * HTML→markdown presentation. All web access goes through `ctx.web`; this - * package never imports a concrete provider package. - * - * Tool registration follows product/app ENABLEMENT, not backend availability: a - * tool stays visible even when its selected provider is missing/misconfigured, - * and execution fails with a structured `WebError` (resolved by the seam at call - * time). That keeps the model schema stable without making plugin load order, - * credential state, or HMR timing part of the model-facing contract. - * + * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` seam. This + * root plugin registers the tools the product has ENABLED, composing the per-tool registration + * helpers (`applyWebSearchTool`, `applyWebFetchTool`). * @module @deepseek-ai/dsh-tool-web */ diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index de804e2bcd..1359828623 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -1,11 +1,8 @@ /** - * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search - * provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool - * (`dsh-tool-web`) + the tool-call timeout policy (`dsh-timeout-policy`), - * exercised through `ctx.tools.execute()` — nothing bypasses the tool registry. - * Fetch hits a real loopback HTTP server (verifying the WORLD); search runs the - * real Exa provider over a stubbed global `fetch` (the network is the one - * boundary we mock). + * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search provider + * (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the + * tool-call timeout policy (`dsh-timeout-policy`), exercised through `ctx.tools.execute()` — + * nothing bypasses the tool registry. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -159,10 +156,8 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc }) it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => { - // A direct seam caller does not go through tools/execute, so the tool-call - // policy never applies; the provider's OWN timeout is the only budget. A - // short per-request hint proves the provider backstop is intact and classifies - // as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT. + // A direct seam caller does not go through tools/execute, so the tool-call policy never + // applies; the provider's own timeout is the only budget. const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( () => undefined, (e: unknown) => e as { code?: string }, diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts index 5c47f3ce59..74b4f2fe1d 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -1,17 +1,4 @@ -/** - * Real-load-path guard for @deepseek-ai/dsh-tool-web. `tool-web` is a NAMESPACE - * plugin with `inject` — so a stray `export default apply` would make the cordis - * Loader's `unwrapExports` (`exports.default ?? exports`) collapse the module to - * the bare `apply` function, DROPPING `inject`. The plugin would then read - * `ctx.web` without having injected it and throw `cannot get property … without - * inject` the moment it loads (postmortem 0001). - * - * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it - * bypasses `unwrapExports`. So this test unwraps the module through the REAL - * `Loader.prototype.unwrapExports` and mounts the result over `ctx.web`, - * exercising the exact path the Loader uses. Prove the guard bites: add - * `export default apply` to `src/index.ts`, watch this go red, revert. - */ +/** Real Loader-path coverage for the namespace plugin's export shape. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' @@ -41,7 +28,6 @@ describe('dsh-tool-web real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolWeb) as Parameters[0] - // A collapsed export shape (dropped inject) would throw "without inject" here. const fiber = await ctx.plugin(unwrapped) expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['web_search', 'web_fetch'])) await fiber.dispose() diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ed332c4508..5c419e7ba1 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -1,21 +1,7 @@ /** - * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public - * HTTP(S) URL with platform-native `fetch` at the repo's Node floor and returns a status - * code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL - * validation, redirect policy, timeout, abort, byte caps, charset decoding, - * content-type classification, binary rejection — but NOT presentation - * (HTML→markdown lives in `@deepseek-ai/dsh-tool-web`). - * - * Redirects are followed manually (`redirect: 'manual'`) so the provider can - * enforce a same-origin-only policy: a cross-origin redirect is refused with - * `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (Claude Code's WebFetch - * uses the same model). It does NOT carry browser cookies, editor/git - * credentials, or implicit access to private services. - * - * SSRF / private-network protection is DEFERRED (see the package RFC); until it - * lands this provider is an SSRF primitive and must not be enabled where it can - * reach sensitive internal targets. - * + * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public HTTP(S) URL with + * platform-native `fetch` at the repo's Node floor and returns a status code plus bounded + * decoded content. * @module @deepseek-ai/dsh-web-fetch-local/provider */ @@ -60,11 +46,8 @@ export class LocalFetchProvider implements WebFetchProvider { if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs) - // One deadline signal fuses the caller's abort with our own timeout, so the - // network request and the streaming read both stop on either. The timeout - // abort carries a TimeoutReason we recover afterward to classify the cause - // (translateAbortOrNetwork), instead of hand-rolling a controller + timer + - // reason-recovery dance. + // One deadline signal fuses the caller's abort with our own timeout, so the network request + // and the streaming read both stop on either. using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT') return await this.followAndRead(request.url, d.signal) } @@ -78,11 +61,7 @@ export class LocalFetchProvider implements WebFetchProvider { const response = await this.requestOnce(currentUrl, signal) if (isRedirectStatus(response.status)) { - // The redirect budget is enforced BEFORE this hop's target is resolved - // or origin-checked, so `maxRedirects: N` follows at most N redirects - // exactly: the (N+1)th redirect is refused as "exceeded" regardless of - // where it points (a same-origin/cross-origin distinction on a hop we - // are not allowed to follow would be the wrong diagnosis). + // Enforce the redirect budget before resolving or validating the next hop. if (redirectsFollowed >= this.limits.maxRedirects) { await response.body?.cancel() throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') @@ -95,10 +74,9 @@ export class LocalFetchProvider implements WebFetchProvider { throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') } const target = resolveRedirect(location, currentUrl) - // Re-validate the target against the same transport hygiene a direct - // request gets: a redirect must not be a back door to a credentialed, - // non-http(s), or over-long URL that validateFetchUrl would reject. A - // rejection here must still cancel the body first (see below). + // Re-validate the target against the same transport hygiene a direct request gets: a + // redirect must not be a back door to a credentialed, non-http(s), or over-long URL + // that validateFetchUrl would reject. let validatedTarget: URL try { validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 2c3f0ede3b..b85d8406da 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -1,15 +1,6 @@ /** - * `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed - * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a - * default-export service): it registers INTO the seam's provider registry, like - * `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`. - * - * The provider talks to DeepSeek's Anthropic-compatible Messages API with the - * native `web_search_20250305` server tool. It reuses `$DEEPSEEK_API_KEY` (no - * new secret) but NOT `$DEEPSEEK_BASE_URL` — the search endpoint is the - * Anthropic-compatible base, distinct from the chat-completions base the LLM - * adapter uses. - * + * `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed `WebSearchProvider` with + * `ctx.web`. * @module @deepseek-ai/dsh-web-search-deepseek */ diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index fca8620e2a..6e9a34a33b 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -1,22 +1,6 @@ /** - * `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's - * Anthropic-compatible Messages API with the native `web_search_20250305` server - * tool enabled. - * - * Unlike a dedicated search endpoint (Exa's `POST /search`, Perplexity's - * `/chat/completions`), this issues a FULL Messages model call carrying a server - * tool, so a search costs a complete model turn in latency and tokens. In return - * DeepSeek runs the search server-side and returns STRUCTURED - * `web_search_tool_result` blocks — this provider parses those blocks and never - * scrapes URLs out of model prose. Strict mode: if the response carries no - * `web_search_tool_result` block (native search did not trigger), it throws - * `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping. - * - * Network requests use platform-native `fetch` at the repo's Node floor, mirroring - * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. - * The Anthropic wire shape is a provider-private detail and does NOT make this - * provider depend on `ctx.llm`. - * + * `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's Anthropic-compatible + * Messages API with the native `web_search_20250305` server tool enabled. * @module @deepseek-ai/dsh-web-search-deepseek/provider */ @@ -101,15 +85,11 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map 1` request can surface the same URL across searches). The seam - * owns the final `maxResults` truncation, so `truncated` is always `false` here. - * - * Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result` - * block is present — native search did not trigger, and prose-scraping is not a - * fallback. + * Map a DeepSeek Anthropic Messages response to a normalized search result. Walks + * `web_search_tool_result` blocks for citeable `web_search_result` items, joins each to its + * citation excerpt as `snippet`, and dedupes by `url` (a `max_uses > 1` request can surface + * the same URL across searches). The seam owns the final `maxResults` truncation, so + * `truncated` is always `false` here. * * @param query - the original request query, echoed on the result. * @param response - the parsed Messages response body. diff --git a/packages/web/web-search-deepseek/src/types.ts b/packages/web/web-search-deepseek/src/types.ts index bd88ed9663..e65783c608 100644 --- a/packages/web/web-search-deepseek/src/types.ts +++ b/packages/web/web-search-deepseek/src/types.ts @@ -1,16 +1,6 @@ /** - * Wire types for DeepSeek's Anthropic-compatible Messages API - * (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool - * enabled. Types only — no runtime code. - * - * DeepSeek returns structured content blocks: `web_search_tool_result` blocks - * carry the citeable `web_search_result` items (`url`/`title`/`page_age`), while - * the snippet/excerpt for a URL lives separately in a `text` block's - * `citations[]` (a `cited_text` keyed by `url`). The provider joins the two. - * - * The Anthropic wire shape is a provider-private detail; it does not make this - * provider depend on `ctx.llm`. - * + * Wire types for DeepSeek's Anthropic-compatible Messages API (`POST {baseURL}/messages`) with + * the native `web_search_20250305` server tool enabled. * @module @deepseek-ai/dsh-web-search-deepseek/types */ diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 0faab1f35a..10f69173e1 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -300,14 +300,7 @@ describe('web-search-deepseek plugin registration', () => { }) it('survives the real Loader unwrapExports path keeping name/inject/Config', () => { - // A stray `export default apply` would make the cordis Loader's - // unwrapExports (`exports.default ?? exports`) collapse the module to the - // bare `apply` function, DROPPING `inject: ['web']` — the plugin would then - // read ctx.web without injecting it and throw "cannot get property … without - // inject" the moment it loads. A hand-built ctx.plugin(namespace) mount - // bypasses unwrapExports and cannot catch that, so drive the real path. - // Prove it bites: add `export default apply` to src/index.ts, watch this go - // red, revert. + // A default export would make Loader discard the required web injection metadata. const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(deepseekPlugin) as Record expect(unwrapped).toBe(deepseekPlugin) diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 48514c07bd..7c7bf64002 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -1,14 +1,6 @@ /** - * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API - * (`POST /search` with highlight contents). Maps Exa's flat `results[]` into the - * seam's normalized `WebSearchResult`. Exa returns no provider-generated answer, - * so `content` is omitted; each result maps to a `WebSearchSource` with `url`, - * `title`, the first highlight as `snippet`, and `publishedDate` as - * `publishedAt`. - * - * Network requests use platform-native `fetch` at the repo's Node floor, mirroring - * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. - * + * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with + * highlight contents). * @module @deepseek-ai/dsh-web-search-exa/provider */ diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index a008438c9a..be8a90c44d 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -1,15 +1,6 @@ /** - * `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity - * search API (an OpenAI-compatible `POST /chat/completions`). Maps the generated - * answer (`choices[0].message.content`) into `content`, and prefers the - * structured `search_results[]` for `sources[]`, falling back to the URL-only - * `citations[]` when `search_results` is absent. - * - * Network requests use platform-native `fetch` at the repo's Node floor, mirroring - * `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape - * is a provider-private detail and does NOT make this provider depend on - * `ctx.llm`. - * + * `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity search API (an + * OpenAI-compatible `POST /chat/completions`). * @module @deepseek-ai/dsh-web-search-perplexity/provider */ diff --git a/packages/web/web-search-perplexity/src/types.ts b/packages/web/web-search-perplexity/src/types.ts index 7b1f2e32b0..a71f805aaa 100644 --- a/packages/web/web-search-perplexity/src/types.ts +++ b/packages/web/web-search-perplexity/src/types.ts @@ -1,13 +1,6 @@ /** - * Wire types for the Perplexity search API - * (`POST https://api.perplexity.ai/chat/completions`, an OpenAI-compatible chat - * shape). Types only — no runtime code. Perplexity returns a generated answer in - * `choices[0].message.content` plus citation surfaces: a structured - * `search_results[]` (preferred) and a URL-only `citations[]` fallback. - * - * The OpenAI-compatible wire shape is a provider-private detail; it does not make - * this provider depend on `ctx.llm`. - * + * Wire types for the Perplexity search API (`POST https://api.perplexity.ai/chat/completions`, + * an OpenAI-compatible chat shape). * @module @deepseek-ai/dsh-web-search-perplexity/types */ diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 5da736a85a..bda6b00788 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -1,17 +1,9 @@ /** - * The web access seam (`ctx.web`): a provider registry plus a provider-selecting - * execution surface for two capabilities — search and fetch. Provider packages - * register concrete backends with `registerSearchProvider` / - * `registerFetchProvider`; the model-facing consumer - * (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and - * routes on the structured {@link WebError} codes selection throws. - * - * The registry half stays close to `LlmService`: a `Map` per - * capability kind, register methods that return disposers, duplicate ids that - * throw, and execution-time resolution that throws when the selected provider is - * absent or unusable — with selection rules that never depend on registration - * order. - * + * The web access seam (`ctx.web`): a provider registry plus a provider-selecting execution + * surface for two capabilities — search and fetch. Provider packages register concrete + * backends with `registerSearchProvider` / `registerFetchProvider`; the model-facing consumer + * (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and routes on the + * structured {@link WebError} codes selection throws. * @module @deepseek-ai/dsh-web */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index f4cda691b8..de1e1517db 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,19 +1,7 @@ /** - * Vocabulary for the web capability seam (`ctx.web`): the search/fetch - * request/result shapes providers produce and consumers format, the provider - * status discriminant selection reads, the execution-control context, and the - * typed error taxonomy. - * - * These types are shared by every provider backend - * (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, - * `@deepseek-ai/dsh-web-fetch-local`, and future backends) and by the - * model-facing consumer (`@deepseek-ai/dsh-tool-web`). Search and fetch share no - * request schema and no business logic, but they are deliberately one seam: - * `ctx.web` is a single web-access middle layer with one provider-selection - * policy, one abort/error vocabulary, and one product-facing configuration - * point. The cost is the parallel `Search`/`Fetch` shapes below; that - * parallelism is intentional. - * + * Vocabulary for the web capability seam (`ctx.web`): the search/fetch request/result shapes + * providers produce and consumers format, the provider status discriminant selection reads, + * the execution-control context, and the typed error taxonomy. * @module @deepseek-ai/dsh-web/types */ @@ -162,39 +150,9 @@ export interface WebFetchProvider { } /** - * Typed web error. Extends {@link HarnessError} so it carries a stable, - * machine-routable `code` (a `string`, like every other seam's error) and - * chains `cause`. `ToolRegistry.execute()` converts a thrown `WebError` into an - * error tool result whose structured metadata exposes the code, so callers - * (hooks, tests, UI) route on it. - * - * The `code` is an open `string`, NOT a closed union: a provider may raise its - * own codes without editing this package, and a consumer must tolerate an - * unknown code (a future provider will introduce ones this file never named). - * The codes split by who owns them — seam-neutral codes any provider may see, - * versus codes specific to a single implementation: - * - * Seam-neutral (raised by `WebService` selection and the shared contract): - * - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable. - * - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered. - * - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its - * `status()` reports unavailable. - * - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers - * exist (selection refuses to pick by registration order). - * - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is - * already registered for that capability kind. - * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. - * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced - * through the seam, including network/transport failure (DNS, connection - * refused, TLS). - * - * Fetch-transport codes (owned by the `dsh-web-fetch-local` implementation; a - * different fetch backend need not raise these and may raise its own): - * - `WEB_INVALID_URL`: the fetch URL is malformed or not http(s). - * - `WEB_BLOCKED_URL`: the fetch URL is rejected by policy (credentials in URL). - * - `WEB_REDIRECT_BLOCKED`: a cross-origin redirect was refused. - * - `WEB_FETCH_TOO_LARGE`: the response exceeded the byte/character cap. - * - `WEB_FETCH_TIMEOUT`: the fetch exceeded its timeout. - * - `WEB_UNSUPPORTED_CONTENT_TYPE`: the response content type cannot be decoded. + * Typed web error. Extends {@link HarnessError} so it carries a stable, machine-routable + * `code` (a `string`, like every other seam's error) and chains `cause`. + * `ToolRegistry.execute()` converts a thrown `WebError` into an error tool result whose + * structured metadata exposes the code, so callers (hooks, tests, UI) route on it. */ export class WebError extends HarnessError {} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 7cd1606b47..29d00f7a2b 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -1,25 +1,9 @@ /** - * The model-facing `workflow` tool: run a JavaScript orchestration script that - * fans out subagents, and return the script's final value. Pure schema + - * lifecycle shaping — script parsing, execution, caps, and cancellation live - * behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine - * swaps in without touching what the model sees. - * - * Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute` - * starts a run and awaits `run.result` inside a `try/finally` that always - * disposes the run, so the script and its children are torn down on every - * path. A non-`completed` stop reason maps to an `isError` tool result (by - * throwing) rather than returning partial output as success. Background - * collection is deferred to the cross-tool background redesign. - * - * Render intent (decided up front, per the render-intent RFC): a `generic` - * card whose title carries the workflow's `meta.name`, read directly from the - * call's `meta` parameter — presentation is a pure function of `args`. - * - * Usage policy ships with the tool as a `tool:` system-prompt - * section (explicit-ask-only guidance) — tool guidance lives in tool plugins, - * never in the deployment persona. - * + * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out + * subagents, and return the script's final value. Pure schema + lifecycle shaping — script + * parsing, execution, caps, and cancellation live behind `ctx.workflows` + * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model + * sees. * @module @deepseek-ai/dsh-tool-workflow */ @@ -184,10 +168,8 @@ export function apply(ctx: Context, config: Config): void { ...exec.signal ? { signal: exec.signal } : {}, }) - // Bridge the tool's abort signal to the run: if the parent step is - // aborted while the script is in flight, cancel the whole run. The - // engine also receives `signal` directly, but an explicit bridge keeps - // the tool's contract local (and covers an engine that ignores it). + // Bridge the tool's abort signal to the run: if the parent step is aborted while the + // script is in flight, cancel the whole run. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal?.addEventListener('abort', onAbort, { once: true }) // `addEventListener` does NOT fire for a signal already aborted before diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 0585889617..0ac608c83b 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -224,13 +224,10 @@ describe('dsh-tool-workflow', () => { describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => { it('an abort releases the tool even when the script parks on a promise no hook owns', async () => { - // Regression for the review-found turn wedge: the tool awaits - // run.result BEFORE its disposing finally, the registry and the loop - // await the tool — so if cancellation could not settle result (a script - // parked on `await new Promise(() => {})`), an aborted turn stayed - // wedged forever. The seam now guarantees result settles within the - // grace of cancel(); this drives that guarantee through the real - // registry + real tool + real engine. + // Regression for the review-found turn wedge: the tool awaits run.result before its + // disposing finally, the registry and the loop await the tool — so if cancellation could + // not settle result (a script parked on `await new Promise(() => {})`), an aborted turn + // stayed wedged forever. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index fff9d8496f..8d3630ac42 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -1,40 +1,7 @@ /** - * The host half of one worker-engine run: spawn the Worker, bridge its child - * RPC onto `ctx.subagents`, fan its observer messages into the engine's - * events, and own cancellation, the settle-within-grace guarantee, and child - * cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always - * ends with `worker.terminate()`, so no thread outlives its run. - * - * The run's `result` promise settles exactly once, from whichever of these - * lands first: the worker's `result` message (a host-side cancellation in - * flight overrides a non-cancelled report — the seam-visible result had not - * settled when cancellation was requested), an unexpected worker death - * (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or - * `'cancelled'` when a cancel was in flight), or the post-cancel grace timer - * (a script that never settles is force-settled `cancelled` and its worker - * terminated — the real kill an in-process engine could not perform). - * - * Children live in a host-side registry (callId → run) as soon as the provider - * accepts them, so cancellation reaches even a pre-publication attempt. The - * host observes `result` immediately but acknowledges the child to the worker - * only after `started` fulfills; readiness failure is a start error and the - * host disposes the attempt because the worker never received a handle. The - * worker drives disposal by RPC on the graceful path, `dispose()` host-drives - * every registered child's disposal immediately (a wedged worker can relay no - * dispose RPC, and child teardown must overlap the grace, not start after it), - * and the registry lets the host abort and dispose every survivor when the - * worker dies or is terminated mid-flight. The three - * paths share ONE disposal per child (memoized by callId; the seam's - * dispose() is idempotent anyway, the memo keeps the bookkeeping and the - * containment warn single). Lifecycle pairing is host-guaranteed the same - * way: every forwarded `agent-start` lives in a ledger, and a start the - * dead or terminated worker never paired is closed by a synthesized - * `agent-end` (outcome `'cancelled'`) before the run settles. On a - * termination path `agentsStarted` reports the - * HOST-observed count (accepted `child-start` messages) — `agent()` calls - * still queued worker-side for a concurrency slot are unknowable then; the - * worker's own count rides the result message on every graceful path. - * + * The host half of one worker-engine run: spawn the Worker, bridge its child RPC onto + * `ctx.subagents`, fan its observer messages into the engine's events, and own cancellation, + * the settle-within-grace guarantee, and child cleanup. * @module @deepseek-ai/dsh-workflow-workerthread/host */ @@ -54,25 +21,7 @@ import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts' /** * Resolve the worker entry and spawn options for the current runtime shape. - * Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the - * entry is the TypeScript sibling and the worker needs the tsx loader - * registered explicitly: a worker thread inherits no transform pipeline from - * vitest (vite transforms in-process, not via a node loader), and passing - * execArgv explicitly also shields the worker from any loader flags the - * parent was started with. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed (execArgv - * pinned empty — hermetic, like the environment). * - * Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm - * escape reaches `process`, and the harness's ambient credentials - * (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as - * `dsh-code-runtime-worker`, stronger than the scrubbed env the - * defensive-patterns rule requires for spawned commands (a shell needs PATH; - * this worker needs nothing). Sole exception: the unbuilt shape forwards - * `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths - * map depends on outside the repo cwd, not a secret). This closes the - * AMBIENT channel only — an escapee still holds process-wide privileges - * like fs access (the README's trust premise stands). * @param init - the run payload, passed as `workerData`. * @returns the entry URL and the Worker options to spawn it with. */ @@ -81,13 +30,8 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti if (!import.meta.url.endsWith('.ts')) { return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } - // Lazy tsx resolution: only the unbuilt shape needs it, so the built - // bundle never requires tsx to be installed. TSX_TSCONFIG_PATH is the one - // variable forwarded through the scrub: tsx finds a tsconfig by searching - // UP from the worker's cwd, and a parent running with its cwd outside the - // repo (the ACP snapshot harness pins the tsconfig through this exact - // variable) would otherwise lose the dsh-* paths map and resolve workspace - // imports to unbuilt lib/ bundles. Loader plumbing, not a secret. + // Lazy tsx resolution: only the unbuilt shape needs it, so the built bundle never requires + // tsx to be installed. return { entry: new URL('./worker.ts', import.meta.url), options: { @@ -161,34 +105,19 @@ export class WorkerRun implements WorkflowRun { } /** - * Cancel the run: the worker is told (its hooks start throwing and the - * script dies at its next await), every host-side child is cancelled NOW on - * BOTH seam channels — the shared request signal aborts and each registered - * child's explicit `cancel()` is called (the seam leaves a provider free to - * honor either, and a worker wedged in a synchronous spin could not relay - * its own per-child cancel RPCs until far too late) — and the grace timer - * arms: a run still unsettled `disposeGraceMs` later force-settles - * `cancelled` and its worker is TERMINATED. Idempotent; the first reason - * wins. + * Cancel the worker and host-owned children, then arm forced settlement. * @param reason - human-readable cause (default `'workflow cancelled'`). */ cancel(reason?: string): void { - // A settled run has nothing left to cancel: without this guard the - // ordinary consumer path (await result, then dispose -> cancel) would arm - // a grace timer nothing ever clears, pinning the run and its Worker - // closure until the grace expires - a bounded leak per completed run. + // Do not arm a grace timer after settlement. if (this.settled || this.cancelReason !== undefined) return this.cancelReason = reason ?? 'workflow cancelled' this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) this.controller.abort(this.cancelReason) - // The explicit channel is driven host-side, not left to the worker: a - // provider honoring only run.cancel() must not wait on a wedged worker's - // ChildCancel relay (those later RPCs land as idempotent no-ops). + // Host-side cancellation still reaches children when the worker is wedged. for (const run of this.children.values()) run.cancel(this.cancelReason) this.graceTimer = setTimeout(() => { - // The worker may no longer speak (it is about to be terminated): pair - // every stranded start before the run settles, so ends precede - // workflow/end. + // Pair stranded child starts before terminal workflow events. this.endStrandedAgents() this.settleResult(this.cancelledResult(this.hostStarted)) void this.worker.terminate() @@ -198,18 +127,8 @@ export class WorkerRun implements WorkflowRun { } /** - * Cancel + bounded settle + termination. Host-drives every registered - * child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC, - * and deferring child teardown to the post-terminate reap would spend the - * whole grace waiting for a quiescence that cannot start, then return with - * the disposals still in flight — so child disposal overlaps the same - * grace the worker gets to settle (the worker's own dispose RPCs join the - * shared per-child disposal). Waits (at most the grace) for the result and - * child quiescence, then terminates the worker unconditionally — the - * thread never outlives its run — and reaps whatever children remain - * (their disposal is contained, not awaited past the grace, the same - * abandonment the seam documents for a slow-disposing child). Idempotent; - * safe on every path. + * Cancel + bounded settle + termination. + * * @returns resolves when the run's resources are released or abandoned. */ dispose(): Promise { @@ -313,12 +232,7 @@ export class WorkerRun implements WorkflowRun { this.children.set(callId, run) const childId = run.id - // Observe settlement IMMEDIATELY, before readiness. A provider may reject - // result and started in the same turn; delaying this handler would make the - // result transiently unhandled. Buffer a forwarding closure so the worker - // still sees ChildStarted before ChildSettled/ChildFailed. Snapshot a - // resolved result now: a provider mutating its resolved object while - // publication is pending must not change what crosses the worker boundary. + // Observe settlement IMMEDIATELY, before readiness. const forwardResult = run.result.then<() => void, () => void>( (result) => { try { @@ -339,12 +253,7 @@ export class WorkerRun implements WorkflowRun { }, ) - // The provider owns the publication boundary. Only acknowledge the child - // after it is real, then flush any result that settled unusually early. A - // readiness rejection is a START failure, not AGENT_RESULT: the worker - // never receives a handle, so the host must also dispose the registered - // attempt. A concurrent host disposal may already have removed it; the - // identity guard preserves the one-disposal memo in that race. + // The provider owns the publication boundary. void run.started.then( () => { this.post(HostToWorkerType.ChildStarted, { callId, childId }) @@ -370,13 +279,9 @@ export class WorkerRun implements WorkflowRun { } /** - * Start (or join) one registered child's disposal; the registry entry - * leaves when it settles. Memoized per callId: the worker's dispose RPC, - * the dispose() host drive, and the reap can all land on the same child — - * the child's `dispose()` runs once and every caller awaits that one - * settlement. A rejection is contained (the subagent seam's dispose() is - * not supposed to reject, but a backend that does anyway must not break - * quiescence): logged, and the child still leaves the registry. + * Start (or join) one registered child's disposal; the registry entry leaves when it + * settles. + * * @param callId - the child's registry key. * @param run - the registered child (the caller looked it up). * @returns resolves when the disposal settled either way; never rejects. diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index e0b8caf3fb..0d0948474b 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -1,41 +1,5 @@ /** - * The `node:worker_threads` workflow engine: the {@link WorkflowService} - * implementation. Runs each script in its OWN worker thread (one run = one - * worker, no pooling — a run is heavyweight, so thread spin-up is noise): the - * body executes in a vm context INSIDE the worker with the workflow hooks - * injected, and `agent()` calls bridge back to `ctx.subagents` over the - * message port — child agents are I/O-bound LLM loops and stay on the host - * event loop; the thread isolates the SCRIPT, the only part that can spin - * synchronously. - * - * TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the - * model's existing bash access — so this engine defends against BUGGY - * scripts, never hostile ones. A worker thread is NOT a security boundary: - * the vm context inside it is escapable by construction, and an escapee - * holds the same process privileges as the host (Node's permission model is - * process-wide); genuine sandboxing (isolated-vm, a separate process) is an - * engine swap behind the seam. What the thread buys, concretely: - * - * - `start()` never blocks the host: the script's initial synchronous slice - * (and any later synchronous spin) occupies the WORKER's event loop, not - * the harness's. - * - Termination is REAL: a script that outlives its post-cancel grace is - * `worker.terminate()`d — nothing of the script survives `dispose()`, - * where an in-process engine could only abandon the spin on its own loop. - * - The value boundary is serialization by construction: everything crossing - * the thread is structured-clone data (and plain JSON before that, by the - * materialization walk in ./realm.ts). - * - * Engine-specific limitations: worker startup (~tens of ms) is paid per run; - * on a termination path `agentsStarted` reports the host-observed child - * count (calls still queued worker-side for a slot are unknowable — see - * ./host.ts); and a worker that dies unexpectedly (an OOM, a script reaching - * `process.exit` through the documented vm escape) settles the run - * `stopReason: 'error'` with the exit diagnostics. - * - * Plugin export shape: a default-exported {@link WorkflowService} subclass - * (the class-based service form, like `dsh-bash-local`). - * + * The `node:worker_threads` workflow engine: the {@link WorkflowService} implementation. * @module @deepseek-ai/dsh-workflow-workerthread */ diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index 848a4fc9b1..1ed1659d3c 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,13 +1,6 @@ /** - * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against - * the shape contract and reject everything else loud, every violation named. - * Meta arrives as plain JSON through the seam (the model-facing tool carries - * it as a schema-validated object parameter) — the engine never evaluates - * script text to obtain it, so no script-controlled code can run on the host - * here (an evaluated meta literal could smuggle getters that spin the host - * outside any vm timeout, the exact escape the worker thread exists to - * prevent). - * + * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape + * contract and reject everything else loud, every violation named. * @module @deepseek-ai/dsh-workflow-workerthread/meta */ diff --git a/packages/workflow/workflow-workerthread/src/protocol.ts b/packages/workflow/workflow-workerthread/src/protocol.ts index d70ad11613..5380da6b94 100644 --- a/packages/workflow/workflow-workerthread/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -1,18 +1,7 @@ /** - * The host⇄worker wire protocol: one string-valued enum of message tags per - * direction, a payload map giving each tag its parameters (the single source - * of truth), and the message unions derived from them. Everything in a - * payload is plain JSON data by construction (the runtime materializes - * script values before they reach a message; the host projects seam results - * down to their JSON fields), so the structured-clone hop never meets a - * value it cannot carry. - * - * Both directions are CLOSED (engine-owned): each side switches on `type` - * and ends with `assertNever` — an unknown message is a protocol bug, never - * something to skip silently. Senders go through a generic - * `post(type, payload)` whose payload parameter is looked up from the map, - * so a tag/payload mismatch is a compile error at the call site. - * + * The host⇄worker wire protocol: one string-valued enum of message tags per direction, a + * payload map giving each tag its parameters (the single source of truth), and the message + * unions derived from them. * @module @deepseek-ai/dsh-workflow-workerthread/protocol */ diff --git a/packages/workflow/workflow-workerthread/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts index 393716c279..9c2790249e 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,32 +1,6 @@ /** - * The engine's value boundary: copy script-realm values into plain JSON data - * — loud about everything JSON cannot carry — and render thrown script - * values to failure text. The script runs in a vm context INSIDE the worker - * thread, so "host" here means the worker-side JavaScript around that - * context; everything that later crosses the thread boundary is JSON by this - * walk, which is what makes the postMessage hop total. - * - * TRUST PREMISE (everything in this module hangs on it): workflow scripts are - * MODEL-WRITTEN, the same trust level as the model's existing bash access, so - * this boundary guards against BUGGY scripts, not hostile ones. It rejects - * loud what JSON would silently mangle — functions, symbols, bigints, - * non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic - * prototypes — because accepted-then-ignored is this repo's banned failure - * mode. It does NOT defend against adversarial values: the walk reads - * properties ordinarily (a getter runs, and whatever it returns is what - * crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly, - * and a proxy is walked through its traps. A hostile script gains nothing - * worth defending here — the vm context inside the worker is escapable by - * construction, so hostile-value containment would be cost without a threat - * model (what the worker thread DOES buy is that a spin occupies the - * worker's loop, not the host's, and termination is real). - * - * The host→realm direction needs no machinery at all: hooks hand the script - * plain values of the worker realm, prototypes included — the script is - * trusted. One consequence is documented in the engine README: an error - * thrown by a hook is built OUTSIDE the script's vm context, so an in-script - * `instanceof Error` check is false; read `name`/`code`/`message` instead. - * + * The engine's value boundary: copy script-realm values into plain JSON data — loud about + * everything JSON cannot carry — and render thrown script values to failure text. * @module @deepseek-ai/dsh-workflow-workerthread/realm */ @@ -75,13 +49,7 @@ function hasPlainPrototype(value: object): boolean { /** * Copy `value` (typically from the vm realm) into plain host JSON data. - * Throws {@link MaterializeError} naming the offending path for anything JSON - * cannot carry losslessly. Properties are read ordinarily — a getter runs and - * its RESULT is materialized; a read that throws surfaces as a - * {@link MaterializeError} carrying the rendered failure. `undefined` is - * accepted only at the ROOT (a script with no `return` value) — the caller - * decides what it means; an `undefined` nested INSIDE a container is a - * violation. + * * @param value - the realm value to materialize. * @param root - the path label for the root value (error messages). * @returns the host-realm copy (plain objects/arrays/scalars only). diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 2add6d537c..b225062879 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,39 +1,8 @@ /** - * Per-run execution state for the engine's THREAD side: the script's vm - * context and its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/ - * `log`/`args`), the concurrency semaphore and caps, cancellation, and the - * drive loop that turns a script settlement into a {@link WorkflowResult}. - * Children are started by RPC to the host through a {@link ChildPort}, so - * this module never touches a cordis context — it runs inside the worker - * thread. - * - * Value boundary (the trust premise lives in ./realm.ts): values ENTERING the - * worker-side host code from the script (hook options, schemas, the return - * value) are materialized by `materializeFromRealm` — a plain walk that - * rejects loud everything JSON cannot carry, which also makes every value - * safe for the later postMessage hop. Values ENTERING the realm (`args`, - * `agent()` results, hook promises and their failures, combinator arrays) are - * handed over DIRECTLY as worker-realm values: the script is model-written - * and trusted, so outer prototypes are not a leak. `args` is cloned once at - * start so a script scribbling on it cannot mutate the session's init object - * (a benign-bug guard; the postMessage clone already isolated the caller). - * - * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, - * unsupported options/schemas, tripped caps, synchronous start refusal, - * pre-publication readiness failure, ready-child result rejection, and - * cancellation) ALWAYS propagate through - * `parallel`/`pipeline` — recognized by `instanceof` against this realm's - * class, which a script inside the vm context cannot forge — and the per-item - * `null` is reserved for child-run failures and ordinary in-stage script - * errors. Every hook-returned promise gets a no-op rejection consumer, so a - * dropped promise cannot surface an unhandled rejection (which would kill the - * worker and read as an engine fault). - * - * There is deliberately NO worker-side abandon channel: a script that never - * settles after a cancel simply never posts a result, and the HOST enforces - * the settles-within-grace guarantee by force-settling `cancelled` and - * terminating the worker — the real kill an in-process engine could not have. - * + * Per-run execution state for the engine's worker side: the script's vm context and its + * injected hooks (`agent`/`parallel`/`pipeline`/`phase`/ `log`/`args`), the concurrency + * semaphore and caps, cancellation, and the drive loop that turns a script settlement into a + * {@link WorkflowResult}. * @module @deepseek-ai/dsh-workflow-workerthread/runtime */ @@ -105,12 +74,8 @@ export class WorkflowExecution { private readonly observer: ExecutionObserver, private readonly children: ChildPort, ) { - // Compile FIRST: a body syntax error must throw out of the constructor - // before any realm state exists. The host pre-parses the identical - // wrapper, so under one Node version this throw is unreachable in - // production — the session still maps it to an error result defensively. - // lineOffset compensates for the wrapper line, so stack traces carry the - // script's own line numbers. + // Compile FIRST: a body syntax error must throw out of the constructor before any realm + // state exists. try { this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${meta.name}`, @@ -163,12 +128,10 @@ export class WorkflowExecution { } /** - * Cancel the run: in-flight children get a cancel RPC (the shared abort - * fanout), waiting `agent()` slots reject, and every future hook call - * throws `CANCELLED` — the script dies at its next await. A script that - * never settles anyway (parked on a promise no hook owns) is the HOST's - * problem: its grace timer force-settles the run and terminates the - * worker. Idempotent; the first reason wins. + * Cancel the run: in-flight children get a cancel RPC (the shared abort fanout), waiting + * `agent()` slots reject, and every future hook call throws `CANCELLED` — the script dies at + * its next await. + * * @param reason - human-readable cause, carried on the CANCELLED error and * into child cancel RPCs. Required: every caller (the session's cancel * message, drive()'s settle-reap) has a concrete reason. @@ -215,10 +178,8 @@ export class WorkflowExecution { // contract. return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started } } finally { - // Reap strays: a script that fired agent() calls without awaiting them - // leaves live children behind after settlement — cancel them all. (The - // per-call wrappers dispose each child; the contain() consumer keeps - // their rejections from going unhandled.) + // Reap strays: a script that fired agent() calls without awaiting them leaves live + // children behind after settlement — cancel them all. if (this.cancelReason === undefined) this.cancel('workflow settled') } } @@ -303,11 +264,7 @@ export class WorkflowExecution { await this.acquireSlot() try { - // Re-check after the acquire: the await yields at least one microtask - // tick even when a slot is free, and a queued waiter resumes a tick - // after its release — a cancel() landing in either window must not - // reach the host (which would refuse anyway, but the refusal reads as - // a start failure rather than the cancellation it is). + // Recheck cancellation after semaphore acquisition because acquire always yields. this.throwIfCancelled() let run: ChildHandle try { @@ -344,11 +301,8 @@ export class WorkflowExecution { try { result = await run.result } catch (error: unknown) { - // A rejected child result is an INFRASTRUCTURE fault relayed by the - // host — distinct from a child that failed and resolved. Pair the - // lifecycle before propagating, and propagate FATAL: an ordinary - // throw would dissolve to a per-item null inside the combinators, - // and a broken provider must not read as a failed child. + // A rejected child result is an INFRASTRUCTURE fault relayed by the host — distinct + // from a child that failed and resolved. if (this.isCancelled()) { this.observer.agentEnd({ ...info, outcome: 'cancelled' }) throw this.cancelledError() diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index b159892021..58a2acbcd1 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -1,19 +1,7 @@ /** - * The worker-side half of the engine: {@link runWorkerSession} wires one - * MessagePort to one {@link WorkflowExecution} — hook progress and child - * starts go out as messages, run control and child lifecycle come back in — - * and posts the run's terminal result exactly once. Deliberately separated - * from the thread bootstrap (./worker.ts): the whole session is drivable - * in-process over a `MessageChannel`, which is where its unit coverage lives - * (code inside a real Worker is invisible to the main process's coverage). - * - * Startup handshake: the session posts `ready` and runs the script only - * after the host's `go` — without it, a cancellation racing the worker's - * boot could arrive AFTER the script's initial synchronous slice already - * ran, and a run cancelled before start must not execute the body at all. - * A `cancel` arriving instead of `go` still releases the gate: `drive()` - * sees the cancelled state and settles without running the body. - * + * The worker-side half of the engine: {@link runWorkerSession} wires one MessagePort to one + * {@link WorkflowExecution} — hook progress and child starts go out as messages, run control + * and child lifecycle come back in — and posts the run's terminal result exactly once. * @module @deepseek-ai/dsh-workflow-workerthread/session */ @@ -141,12 +129,10 @@ export function requireParentPort(port: MessagePort | null): MessagePort { } /** - * Run one workflow script to settlement against `port`, posting the terminal - * result message exactly once; resolves after that post (stray children may - * still be winding down through the port — the host owns their teardown and - * ultimately terminates the thread). Never rejects: a constructor failure - * (unparseable body — host pre-parse makes this a Node-version-skew signal) - * is reported as an `error` result rather than dying without a result. + * Run one workflow script to settlement against `port`, posting the terminal result message + * exactly once; resolves after that post (stray children may still be winding down through the + * port — the host owns their teardown and ultimately terminates the thread). + * * @param port - the channel to the host (the real `parentPort`, or one side * of an in-process `MessageChannel` in tests). * @param init - the run payload the host provided as `workerData`. diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index ee5faccdda..90799da8b0 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -1,11 +1,6 @@ /** - * Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init - * payload and the child-port interfaces the worker-side runtime consumes. - * The host⇄worker MESSAGE protocol lives in ./protocol.ts; everything here - * that a message transports (`ChildStartRequest`, `ChildResult`) is plain - * JSON data by construction, so the structured-clone hop never meets a value - * it cannot carry. Types only, per the package convention. - * + * Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init payload and + * the child-port interfaces the worker-side runtime consumes. * @module @deepseek-ai/dsh-workflow-workerthread/types */ diff --git a/packages/workflow/workflow-workerthread/src/worker.ts b/packages/workflow/workflow-workerthread/src/worker.ts index f468ad9a53..0deb964883 100644 --- a/packages/workflow/workflow-workerthread/src/worker.ts +++ b/packages/workflow/workflow-workerthread/src/worker.ts @@ -1,11 +1,5 @@ /** - * The worker-thread entry the engine spawns: bootstrap ./session.ts on the - * real `parentPort`. Deliberately a single statement — every piece of logic - * lives in `runWorkerSession`, which the unit suite drives in-process over a - * `MessageChannel` (code inside a real Worker is invisible to main-process - * coverage); loading this module on the main thread throws via - * `requireParentPort`, which is how the suite covers the file itself. - * + * The worker-thread entry the engine spawns: bootstrap ./session.ts on the real `parentPort`. * @module @deepseek-ai/dsh-workflow-workerthread/worker */ diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 23d4de0f07..eb231b5d34 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -21,21 +21,9 @@ function fakeParent(): Agent { vi.setConfig({ testTimeout: 30_000 }) /** - * `vi.waitFor` with a contention-proof default timeout: the 1s default - * flaked repeatedly on the CI coverage lane, where worker-thread cold start - * (CPU-bound — a fresh thread compiles the runtime) competes with three - * sibling vitest workers for CPU. The 10s default is for exactly those - * races — waiting for a worker to start, run its first script line, or - * deliver an async child-registration message to the host. It is NOT for a - * wait that asserts the HOST reacted PROMPTLY to something that already - * happened (a settled result, an observed worker death): those keep an - * explicit tight override below, or the generous default would silently - * accept a multi-second regression in host-side reap latency as passing - * (proven by injecting a 6s delay into one such reap and watching the - * un-overridden version of this helper still pass in ~6s). - * @param assertion - retried until it stops throwing or the timeout elapses. - * @param timeout - override for a wait that must stay deliberately tight. - * @returns resolves when the assertion passes. + * `vi.waitFor` with a contention-proof default timeout: the 1s default flaked repeatedly on + * the CI coverage lane, where worker-thread cold start (CPU-bound — a fresh thread compiles + * the runtime) competes with three sibling vitest workers for CPU. */ function waitFor(assertion: () => void, timeout = 10_000): Promise { return vi.waitFor(assertion, { timeout, interval: 50 }) @@ -534,11 +522,9 @@ describe('dsh-workflow-workerthread', () => { it('a child-start racing the host cancel is refused: no child starts after cancellation', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - // Cancel from INSIDE the log listener: the worker has already posted - // its child-start (queued right behind the log message), so the host - // processes it with cancelReason set — the refusal arm no real-world - // timing can hit reliably. (The closure runs only after `handle` below - // is initialized — the listener fires on the worker's first message.) + // Cancel from inside the log listener: the worker has already posted its child-start + // (queued right behind the log message), so the host processes it with cancelReason set — + // the refusal arm no real-world timing can hit reliably. ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') }) const handle = ctx.workflows.start({ ...scripted("log('mark')\nreturn await agent('late')"), parent }) const result = await handle.result @@ -553,12 +539,9 @@ describe('dsh-workflow-workerthread', () => { ctx.on('workflow/log', (_info, message) => { narration.push(message) }) ctx.on('workflow/phase', (_info, title) => { narration.push(`phase:${title}`) }) const handle = ctx.workflows.start({ - // The sync spin keeps the worker's loop busy so the cancel message - // cannot be processed before the script settles `completed` — the - // worker posts a completed result that must LOSE to the in-flight - // host cancellation. The trailing narration exercises host-side - // suppression: posted pre-cancel-processing worker-side, arriving - // post-cancel host-side. + // The sync spin keeps the worker's loop busy so the cancel message cannot be processed + // before the script settles `completed` — the worker posts a completed result that must + // LOSE to the in-flight host cancellation. ...scripted(` log('started') const end = Date.now() + 1000 @@ -694,11 +677,8 @@ describe('dsh-workflow-workerthread', () => { }) const result = await handle.result expect(result.stopReason).toBe('completed') - // BEFORE dispose(): the settlement itself must have aborted the signal — - // without it this child would stay live until dispose's terminate. This - // is a HOST-PROMPTNESS claim, not a cold-start race — a tight explicit - // bound (unlike the file default) so a multi-second reap regression - // cannot pass by outlasting the wait. + // before dispose(): the settlement itself must have aborted the signal — without it this + // child would stay live until dispose's terminate. await waitFor(() => { expect(aborted).toEqual(['workflow settled']) }, 1000) await handle.dispose() }) @@ -730,13 +710,10 @@ describe('dsh-workflow-workerthread', () => { // reach this child, the assertion below would time out first. await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 }) const handle = ctx.workflows.start({ - // The stray child's start RPC reaches the host, then the script wedges - // its own worker in a synchronous spin: the worker cannot process the - // Cancel message, so it can relay NO ChildCancel RPC — only the host's - // own children loop can deliver the explicit cancel in time. The - // microtask yields let the agent() continuation POST its child-start - // before the spin seizes the worker's loop (the posted message needs - // no further worker-loop turns to reach the host). + // The stray child's start RPC reaches the host, then the script wedges its own worker + // in a synchronous spin: the worker cannot process the Cancel message, so it can relay + // NO ChildCancel RPC — only the host's own children loop can deliver the explicit + // cancel in time. ...scripted(` agent('wedged child') for (let i = 0; i < 20; i++) await null @@ -762,11 +739,7 @@ describe('dsh-workflow-workerthread', () => { config: { provider: 'stub', maxConcurrentAgents: 8, disposeGraceMs: 400 }, }) const handle = ctx.workflows.start({ - // Same shape as the wedged-cancel test above: the child's start RPC - // reaches the host, then the script seizes its worker's loop, so the - // worker can relay NO dispose RPC — the host's own dispose() drive is - // the only thing that can start (and finish) this child's disposal - // before the grace runs out. + // A wedged worker leaves host disposal as the only path to child quiescence. ...scripted(` agent('wedged child') for (let i = 0; i < 20; i++) await null @@ -967,10 +940,9 @@ describe('dsh-workflow-workerthread', () => { }) ctx.on('workflow/end', () => { order.push('run-end') }) const handle = ctx.workflows.start({ - // Same choreography as the force-settle pairing test, but the worker - // DIES (the documented vm escape) instead of being terminated: the - // exit path must close slow's pair from the ledger too. The escaped - // setTimeout lets the already-posted messages flush before the kill. + // Same choreography as the force-settle pairing test, but the worker DIES (the + // documented vm escape) instead of being terminated: the exit path must close slow's + // pair from the ledger too. ...scripted(` const p = agent('slow') await agent('fast') diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 19f52b5d88..64d2d367ac 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -1,23 +1,7 @@ /** - * The workflow capability seam (`ctx.workflows`): an abstract service defining - * WHAT a workflow engine does — execute a model-written orchestration script - * that fans out subagents — without saying HOW. Implementations subclass - * {@link WorkflowService} and register as the `workflows` service (one - * implementation per context, cordis' standard duplicate-service behavior); - * the implementation is `@deepseek-ai/dsh-workflow-workerthread`, which runs each - * script in its own worker thread. Hardened engines (an isolated-vm or - * separate-process sandbox) swap in without touching the model-facing tool - * that consumes them (`@deepseek-ai/dsh-tool-workflow`). - * - * The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they - * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} - * — a listener must not gain `cancel`/`dispose`; control stays with the - * `start()` caller holding the run. Every emit is per-listener contained (a - * throwing subscriber is logged, never propagated) and every listener gets its - * own payload clone (mutating it corrupts nothing), so one bad observer can - * neither strand a live run, starve later listeners, nor poison another - * listener's view. - * + * The workflow capability seam (`ctx.workflows`): an abstract service defining what a workflow + * engine does — execute a model-written orchestration script that fans out subagents — without + * saying how. * @module @deepseek-ai/dsh-workflow */ @@ -119,28 +103,9 @@ export type WorkflowEventName = | 'workflow/end' /** - * The workflow-seam error codes. Every one of these is FATAL when it reaches - * a script (see {@link WorkflowError.fatal}): the combinators re-throw it - * instead of dissolving it into an ordinary per-item `null`. - * - * - `SCRIPT_PARSE` — the script (or its meta statement) does not parse. - * - `META_INVALID` — the meta block evaluated but fails the shape contract. - * - `INVALID_ARGUMENT` — a hook was called with malformed arguments. - * - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support - * (deferred: `effort`/`isolation`/`agentType`) or does not know. - * - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output - * subset (see dsh-tools). - * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. - * - `AGENT_START` — synchronous subagent start or the provider's asynchronous - * publication/readiness boundary failed before cancellation took precedence. - * - `AGENT_RESULT` — a run whose readiness FULFILLED had its `result` REJECT: an - * infrastructure fault at the subagent seam, even if the rejection settled - * before readiness. This is distinct from a child that failed and resolved - * (which is the per-item `null`, never an error). - * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary - * is not plain JSON data. - * - `CANCELLED` — the run was cancelled; pending and future hooks reject - * with this (the script-kill mechanism). + * The workflow-seam error codes. Every one of these is FATAL when it reaches a script (see + * {@link WorkflowError.fatal}): the combinators re-throw it instead of dissolving it into an + * ordinary per-item `null`. */ export type WorkflowErrorCode = | 'SCRIPT_PARSE' @@ -185,31 +150,9 @@ export function isFatalWorkflowError(error: unknown): boolean { } /** - * Abstract workflow execution service. Subclass, implement {@link 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: - * - {@link start} throws synchronously for a request that cannot begin (an - * unparseable script, an invalid meta block). Once it returns a - * {@link 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 {@link 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. + * Abstract workflow execution service. Subclass, implement {@link 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). */ export abstract class WorkflowService extends Service { constructor(ctx: Context) { @@ -225,25 +168,13 @@ export abstract class WorkflowService extends Service { abstract start(request: WorkflowStartRequest): WorkflowRun /** - * Emit one `workflow/*` lifecycle event with PER-LISTENER containment and - * PER-LISTENER payload snapshots: each subscriber is dispatched individually - * with its OWN structural clone of the payload (the payloads are plain JSON - * data by the seam contract), so a listener mutating what it received can - * corrupt neither the engine's live state nor any other listener's or later - * event's view; a thrown listener is logged (never propagated — the logging - * itself is total, even for a thrown value whose own string coercion - * throws), so one bad subscriber can neither fail the engine mid-run, - * surface as an unhandled rejection on a detached settle hook, nor starve - * the listeners registered after it (cordis `emit` halts on the first throw - * — same guarantee as the subagent seam's lifecycle emits). + * Emit isolated payload snapshots and contain each lifecycle listener independently. * @param name - the `workflow/*` event to dispatch. * @param args - the event's payload, matching its declared signature. */ protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void { for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) { try { - // The declared workflow/* signatures are all void-returning emits; the - // dispatch callback applies the payload tuple. ;(callback as (...payload: unknown[]) => void)(...structuredClone(args)) } catch (error: unknown) { this.ctx.logger.warn(`workflow: ${name} listener threw: ${renderListenerError(error)}`) @@ -253,19 +184,15 @@ export abstract class WorkflowService extends Service { } /** - * Total renderer for a listener-thrown value: the containment catch must never - * itself throw, and `String(error)` does when the value's own `toString` / - * `Symbol.toPrimitive` throws. Local rather than an engine package's renderer - * — the seam sits below every engine and cannot import one. + * Render a thrown value without weakening listener containment. * @param error - any thrown value. - * @returns `String(error)`, or a fixed label when even coercion throws. + * @returns string form or a fixed fallback when coercion throws. */ function renderListenerError(error: unknown): string { try { return String(error) } catch { - // Only a throwing toString/Symbol.toPrimitive lands here; the fixed label - // keeps the containment guarantee total. + // String coercion itself is untrusted. return '[unrenderable thrown value]' } } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 66da4f1268..1fa30754f4 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -106,16 +106,7 @@ export interface WorkflowResult { } /** - * The handle the consumer holds while a script executes. The consumer awaits - * `result`, may `cancel` mid-flight, and MUST `dispose` on every path. - * `result` does NOT reject — a script failure resolves with `stopReason: - * 'error'` — and once the run is cancelled it SETTLES within the engine's - * bounded grace even if the script itself never settles (the engine - * force-settles `cancelled`; what becomes of the script is engine-documented - * — the worker-thread engine terminates its worker), so a consumer awaiting - * `result` is never wedged past a cancellation. `dispose()` = cancel + that - * bounded settle + child quiescence; it never hangs on a stuck script and is - * safe to call on every path (idempotent). + * The handle the consumer holds while a script executes. */ export interface WorkflowRun { readonly id: WorkflowRunId diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 2d12a66d8f..4fb22ea32b 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -176,12 +176,7 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { /** * Enforce the packages/ hierarchy SHAPE: every package lives at exactly - * `packages//`. A group dir is a pure container — it holds packages, - * never sources of its own — so it must NOT carry a package.json, and a package - * must NOT sit directly at the `packages/` root (the old flat layout) nor nest a - * level deeper. The group NAMES are open on purpose: a new group may be added - * without touching this gate, but the depth-2 shape is fixed. This is what keeps - * a stray flat package or an over-nested one from regressing the hierarchy. + * `packages//`. */ function checkHierarchyShape(): string[] { const errors: string[] = [] diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 235b8f21fa..ad33536ecf 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,11 +1,11 @@ { - "AGENTS.md": 1802, - "docs/AGENTS.md": 1315, + "AGENTS.md": 1370, + "docs/AGENTS.md": 1175, "docs/architecture.md": 1790, "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, - "examples/AGENTS.md": 705, - "packages/AGENTS.md": 450, + "examples/AGENTS.md": 462, + "packages/AGENTS.md": 200, "packages/README.md": 710 } diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index e57f3710ee..06d018b589 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -1,27 +1,7 @@ /** - * Doc-sync gate (doc-sync-enforcement RFC, part 1): typecheck the fenced `ts` code blocks in our - * Markdown so documentation can't drift from the API it documents. - * - * Every ```ts block in README.md, docs/** and packages/* /README.md is - * extracted to a temp typecheck project and compiled against the workspace - * sources through the same project-reference boundaries used by repo - * typecheck. A block that is a deliberate sketch rather than compilable code - * opts out with an explicit ` ```ts ignore-check ` info string — the opt-out - * is visible in the source, and this script reports the ratio so the escape - * hatch can't quietly become the norm. A third info string, - * doc-typecheck.ts recognizes four more fence variants and skips all four (each - * is a separately-checked category, not an unchecked sketch, so none counts in - * the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that - * `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a - * generated event/service signature fragment in the cordis catalog (a bare - * signature is not standalone-compilable; the catalog is generated and frozen by - * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate), - * ` ```ts persistence-catalog ` is a generated log-event payload fragment in the - * persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`), - * and ` ```ts config-catalog ` is a generated verbatim config declaration in the - * plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`). - * - * Run: `tsx scripts/doc-typecheck.ts`. + * Typecheck Markdown `ts` fences against workspace sources. `ignore-check` + * fences are reported as opt-outs; generated catalog fragments and + * `type-equiv` blocks are skipped here because their owning gates verify them. */ import { execFileSync } from 'node:child_process' @@ -31,30 +11,7 @@ import ts from 'typescript' const root = resolve(import.meta.dirname, '..') -/** - * How a fenced block participates in this gate: - * - `check` (` ```ts `) — compiled. - * - `ignore` (` ```ts ignore-check `) — a deliberate sketch; skipped, and - * counted in the opt-out ratio so the escape hatch can't quietly take over. - * - `type-equiv` (` ```ts type-equiv `) — a verbatim paste of a source type - * definition, drift-checked by `scripts/verify-type-equiv.ts` against the - * source symbol. Skipped HERE (it is not standalone-compilable — no imports) - * and EXCLUDED from the opt-out ratio: it is a separate fully-checked - * category, not an unchecked sketch. - * - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service - * signature fragment in the cordis catalog. Skipped HERE for the same reason - * (a bare signature fragment has no imports and does not stand alone) and - * EXCLUDED from the opt-out ratio: the catalog is generated and frozen by - * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate. - * - `persistence-catalog` (` ```ts persistence-catalog `) — a generated - * log-event payload fragment in the persistence catalog. Same treatment for - * the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its - * `--check` freshness gate. - * - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config - * declaration in the plugin config catalog (a lone declaration referencing - * imported types does not stand alone). Same treatment for the same reason; - * frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate. - */ +/** Classification of a TypeScript fence and the gate that owns it. */ type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog' /** One extracted code block. */ @@ -66,8 +23,7 @@ interface Block { code: string } -/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog / - * ts persistence-catalog / ts config-catalog block from one Markdown file. */ +/** Extract every recognized TypeScript fence from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') const lines = text.split('\n') @@ -87,7 +43,7 @@ function extractBlocks(absPath: string): Block[] { open = null return } - // opening fence — only care about ts blocks + // Ignore non-TypeScript fences. const info = (fence[2] ?? '').trim() const kind: BlockKind | null = info === 'ts' ? 'check' @@ -145,11 +101,10 @@ files.sort() const all = files.flatMap(extractBlocks) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') -// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified -// elsewhere (verify-type-equiv.ts and each catalog generator's `--check` -// freshness gate), not here: neither compiled nor counted toward the opt-out -// ratio (each is a separate fully-checked category, not an unchecked sketch). -// The ratio's denominator is therefore the compile-eligible blocks only. +// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified elsewhere +// (verify-type-equiv.ts and each catalog generator's `--check` freshness gate), not here: +// neither compiled nor counted toward the opt-out ratio (each is a separate fully-checked +// category, not an unchecked sketch). const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 1cbcc007bd..e718c94edd 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -1,68 +1,8 @@ /** - * Generate (and verify) the plugin config catalog in docs/config-catalog.md. - * - * The page is the DEPLOYMENT-axis reference: for every harness package a - * `cordis.yml` entry can load, the exact config surface its `apply` function or - * service constructor receives — pasted VERBATIM from source (the `export - * interface Config` declaration with its JSDoc), plus resolved links for every - * type the declaration references. It complements the wiring-axis cordis - * catalogs (events + services, what a plugin AUTHOR listens to and calls) the - * same way the tool catalog complements them for the model-facing axis. - * - * The catalog is FULLY GENERATED from source — never hand-edit it. Like the - * cordis catalog (and unlike the tool catalog, which must boot plugins), this - * is a pure-AST pass: every config type is a static declaration and every - * schemastery schema is a static `z.object`/`z.intersect` literal, so - * generation cannot drift and a regenerate-and-diff freshness check (`--check`) - * gates staleness. Because generation enumerates every package under - * `packages//`, a brand-new plugin cannot be silently - * undocumented: it must classify as configurable, config-free, seam, or - * library, and an unclassifiable entry hard-errors the generator. - * - * `tsx scripts/gen-config-catalog.ts` → write the catalog - * `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed - * catalog is stale (CI / - * pre-push gate) - * - * What the walk enforces (aggregated into one error, like the sibling - * generators): - * - * - CLASSIFICATION is total. Every package entry resolves, mirroring the - * cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a - * loadable plugin (default class / `apply` function), an abstract seam - * class, or a plain library. Anything else is an error, not a skip. - * - The CONFIG TYPE is the declared type of the plugin's second parameter - * (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis - * actually passes — and it must resolve to a declaration inside the owning - * package (entry file or a package-local relative import). - * - Every property of a pasted declaration carries non-empty JSDoc prose: the - * paste IS the documentation, so an undocumented field is a gate failure, - * the same forcing function the events catalog applies via `@mode`. - * - Every type NAME a pasted declaration references resolves: pasted - * transitively when package-local, linked when it is another plugin's - * config type / a core-data-structures entry / a workspace or external - * import. An unresolvable name is an error, and so is a NAME COLLISION — - * two distinct declarations, or a declaration and an import, sharing one - * name across the closure (a verbatim fence has a single flat namespace) — - * never a silent skip. - * - The runtime schemastery schema (`Config` export or `static Config`), - * when present, is walked statically — `z.object` keys, nested object/array - * compositions as key PATHS (`agents[].id`), and `z.intersect` composition - * across packages — and every schema-validated key path must be locatable - * on the declared config type, resolving package-local and - * workspace-imported types, re-export chains, intersections, utility - * wrappers, and indexed access. The paste cannot hide a loader-accepted - * field, top-level or nested. A path that crosses a type the walk cannot - * enumerate (an external package's type) is skipped, never mis-reported, - * and nested keys under dynamic-key shapes (`z.dict`) or union alternatives - * contribute no paths. The reverse direction is deliberately NOT checked: a - * declared field may be a runtime-only seam the schema excludes (e.g. the - * ACP bridge's test-injected `stream`). - * - * Config fences use the ` ```ts config-catalog ` info string: doc-typecheck - * recognizes it and skips compilation (a lone interface referencing imported - * types is not standalone-compilable, like the ` ```ts cordis-catalog ` - * signature blocks). + * Generate `docs/config-catalog.md` from package entry points, config types, + * JSDoc, and static Schemastery schemas. Every package must classify, referenced + * types must resolve without collisions, and schema paths must exist on the + * declared config type. `--check` verifies the committed artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -372,10 +312,8 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul */ function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set): PathLookup { if (steps.length === 0) return 'found' - // Guard recursion at NAMED declarations only — the sole way a walk can loop - // (a recursive interface/alias). Structural nodes must not be guarded: a - // first child shares `.pos` with its parent, so a span-keyed guard there - // would mistake ordinary descent for a cycle. + // Guard recursion at NAMED declarations only — the sole way a walk can loop (a recursive + // interface/alias). if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { const key = `${ctx.abs}:${node.pos}:${steps.length}` if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop @@ -775,10 +713,8 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { } } - // Second phase: fold composed schemas' key paths in, then walk every - // schema-validated path against the declared config type. Only a definite - // miss is a violation — a path through a shape the walk cannot enumerate - // stays silent rather than mis-reporting. + // Second phase: fold composed schemas' key paths in, then walk every schema-validated path + // against the declared config type. const byName = new Map(entries.map(e => [e.pkg, e])) for (const entry of entries) { if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 34583d2ead..331821b8ff 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -1,27 +1,7 @@ /** - * Generate (and verify) the runtime cordis API catalog the `cordis_inspect` - * tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts. - * - * The artifact is the machine-readable sibling of docs/cordis-catalog: it - * reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the - * same JSDoc-completeness-enforcing AST walk), so the API the model reads at - * runtime and the API the docs render cannot diverge. Emitted as a typed - * TypeScript data module (not JSON): it compiles under the package tsconfig, - * passes lint and the export-JSDoc gate, and is trivially covered by import. - * - * The data is trimmed for a model-facing text surface: per service the - * `ctx.` name, the first sentence of the class doc, and the raw method - * signatures; per event the name, `@mode`, signature, and first sentence of - * doc; the SHAPES of every exported interface/type-alias the service - * signatures reference (transitively — so a model can see that e.g. a - * `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the - * curated inherited `ctx` surface shared with the docs catalog. Source - * pointers are dropped (a `file:line` means nothing to the model) and entries - * are sorted deterministically. - * - * `tsx scripts/gen-cordis-api.ts` → write the artifact - * `tsx scripts/gen-cordis-api.ts --check` → exit 1 if the committed file is - * stale (CI / pre-push gate) + * Generate the model-facing Cordis API data module from the same event/service + * collector as the documentation catalogs. Output includes concise docs, + * signatures, and referenced public type shapes; `--check` verifies freshness. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -47,12 +27,7 @@ function quote(value: string): string { return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'` } -/** - * Every exported `interface` / `type` declaration under `packages///src`, - * printed without comments, keyed by name. A name declared in more than one - * package (e.g. each plugin's `Config`) is ambiguous and dropped entirely — - * serving the wrong package's shape is worse than serving none. - */ +/** Collect uniquely named exported interface and type shapes. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) const decls = new Map() @@ -78,11 +53,7 @@ function collectTypeDecls(scanRoot: string = root): Map { return decls } -/** - * The transitive closure of type names referenced by the seed texts: every - * collected declaration whose name appears (word-bounded) in a seed or in an - * already-included declaration, sorted by name. - */ +/** Resolve the transitive public type shapes referenced by seed text. */ function referencedTypes(seeds: string[], decls: Map): { name: string; declaration: string }[] { const included = new Map() let frontier = seeds diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 32ea8539c6..715540a9bd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -1,55 +1,8 @@ /** - * Generate (and verify) the cordis events and services catalogs in - * docs/cordis-catalog/events.md and docs/cordis-catalog/services.md. - * - * The two pages are the WIRING-axis reference, one axis each: every cordis - * event a plugin can listen to (exact signature + dispatch mode) and every - * `ctx.` service it can call (exact public interface). They complement the - * core-data-structures catalog (the VOCABULARY axis — the types these - * signatures move around). - * - * The catalogs are FULLY GENERATED from source — never hand-edit them. The - * codebase is disciplined enough that a pure-AST pass captures the whole - * truthful surface: every event/service is a string literal that round-trips - * to a static `interface Events` / `interface Context` declaration (no - * dynamically-named events, no runtime-only services). So the committed files - * are build artifacts and a regenerate-and-diff freshness check (`--check`) - * makes drift structurally impossible. Because generation enumerates source - * rather than checking a hand-written subset, a brand-new event cannot be - * silently undocumented — it appears in the next regenerate, and an - * un-regenerated file fails `--check`. - * - * `tsx scripts/gen-cordis-catalog.ts` → write both catalogs - * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed - * catalog is stale (CI / - * pre-push gate) - * - * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in - * full from source: signature, the `@mode` badge, and the declaration's JSDoc. - * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag - * — the generator hard-errors on a missing tag, and where the signature shape is - * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) - * it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag, - * the walk enforces JSDoc COMPLETENESS on the whole harness surface (the - * jsdoc-completeness-gate RFC): every event and public service method carries - * description prose; every payload parameter has a non-empty `@param` (`this` - * receivers and the trailing waterfall `next` are exempt — next's semantics are - * documented once by the mode); a service method with a non-`void`/ - * `Promise` return carries a non-empty `@returns` and needs an EXPLICIT - * return type annotation (a pure-AST walk cannot classify an inferred return); - * a stale `@param` naming no real parameter errors. Violations aggregate into - * ONE error listing every offender. The tags are enforcement-only: parseJsDoc - * stops prose at the first block tag, so they never change the rendered - * catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with - * the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so - * "documented" means the same thing on both surfaces. The INHERITED - * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author - * also sees; it is rendered tersely (name + one-line + source pointer) from a - * curated table in this script, NOT elevated to the harness tier's prominence. - * - * Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck - * recognizes it and skips compilation (the signatures are fragments, not - * standalone-compilable, like the ` ```ts type-equiv ` blocks). + * Generate the Cordis event and service catalogs from static declarations. + * The walk enforces event modes plus JSDoc parameter/return completeness; + * inherited Cordis services come from the curated table below. `--check` + * verifies both committed artifacts. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -65,20 +18,8 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md' * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ const FENCE = 'ts cordis-catalog' -/** - * Cross-link map: a type name that appears in a signature → the - * core-data-structures page that documents it (path relative to the catalogs' - * folder). - * Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json — - * that manifest documents the `…Map` symbols (`ContentBlockMap`) while - * signatures reference the derived UNION names (`ContentBlock`), and it lists a - * few symbols on two pages. Here each name resolves to exactly one PRIMARY page. - * Shared with `gen-config-catalog.ts` (each caller prefixes its own relative - * path to `core-data-structures/`), so both catalogs cross-link identically. - * TODO(catalog-type-links): add a verifier or generator for link-map coverage - * so new hook-era decision types like `PromptDecision` / `PreToolDecision` do - * not silently appear in signatures without a "Types:" link. - */ +/** Primary core-data-structures page for signature types shared by both catalog generators. */ +// TODO(catalog-type-links): verify or generate link-map coverage. export const LINK_MAP: Record = { Agent: 'core.md', ContentBlock: 'core.md', @@ -215,10 +156,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) } if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) - // Payload parameters need a non-empty @param each. Exempt the `this` - // receiver annotation (not payload) and the trailing waterfall `next` - // (mode machinery, documented once by @mode semantics). Documenting an - // exempt parameter anyway is allowed — only absence is checked. + // Payload parameters need a non-empty @param each. const { params } = parseTags(raw) checkParams(where, 'event', member.parameters, params, sf, p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) @@ -269,10 +207,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue - // Only the PUBLIC callable surface a `ctx.` consumer sees. Drop - // private/protected (a protected method like `notifyTaskDone` is a - // subclass hook, not something a plugin calls through `ctx.bash`) and - // static (not reachable through the instance). + // Only the PUBLIC callable surface a `ctx.` consumer sees. const nonPublic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 074cfd83d2..05f6516e80 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1,21 +1,5 @@ /** * Generate (and verify) the relationship-diagram docs. - * - * This is the relationship layer above the existing catalogs: - * - module-graph.md answers "which packages depend on which packages?" - * - cordis-catalog/ answers "which events and services exist?" - * - tool-catalog.md answers "which tools does the model see?" - * - generated relationship diagrams answer "how do those pieces fit together?" - * - * Generated pages discover the enumerable facts from source. Hybrid pages use - * discovered inventory plus small manifests for policy that source cannot infer - * (for example, whether a package is an implementation or consumer in a seam). - * Curated pages are still emitted here so the graph docs are one regenerated unit, - * but their diagrams intentionally explain flow and ownership rather than - * pretending to enumerate every source edge. - * - * `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs - * `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale */ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -597,13 +581,10 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc } const target = expr.expression.getText(sf) if (target === 'ctx' || target === 'this.ctx') return true - // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused - // dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup - // context (`childCtx`), the agent's own context handle (`this.loopCtx`), and - // the session store's captured dispatch context (`emitCtx`). Conventional - // receiver names, pinned by the fused-dispatch convention; a rename here - // must update this list (the producer/consumer matrix silently losing a - // dispatcher or listener is the failure mode this list exists to prevent). + // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused dispatcher (`events` + // from `agentEvents(ctx, agent)`), an agent's setup context (`childCtx`), the agent's own + // context handle (`this.loopCtx`), and the session store's captured dispatch context + // (`emitCtx`). return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx' } @@ -650,13 +631,9 @@ function renderEventRelations(pkgs: Pkg[]): string { const relation = relations.get(event.name) ?? { dispatchers: new Map>(), listeners: new Set() } lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } - // Completeness guard: every DECLARED event must have at least one dispatcher - // edge — a zero-dispatcher row is either dead vocabulary or (the observed - // failure mode) a dispatch spelling the AST scan does not recognize, silently - // dropping the producer from the matrix. Fail the generation loud instead: - // teach the scan the new spelling, add a DYNAMIC_EVENT_DISPATCHERS override, - // or remove the dead event. Zero LISTENERS is deliberately legal — an event - // dispatched for out-of-repo plugins is an ordinary extension point. + // Completeness guard: every DECLARED event must have at least one dispatcher edge — a + // zero-dispatcher row is either dead vocabulary or (the observed failure mode) a dispatch + // spelling the AST scan does not recognize, silently dropping the producer from the matrix. const undispatched = [...events] .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0) .map(event => event.name) diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 54dbcf2773..ea59c173d8 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -1,21 +1,5 @@ /** * Generate (and verify) the module dependency graph in docs/module-graph.md. - * - * The architectural shape of the harness lives implicitly in each package's - * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror - * these as `workspace:^` plus test-only extras, which would add noise). This - * script reads every `packages/* /* /package.json`, keeps only the - * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a - * GitHub-viewable Mermaid graph grouped by `packages//` plus a - * dependency table. - * - * The file is fully generated — never hand-edit it. Output is deterministic - * (packages and edges sorted) so a regenerate-and-diff freshness check is - * stable. - * - * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md - * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file - * is stale (CI / pre-push gate) */ import { dirname, resolve } from 'node:path' @@ -177,10 +161,8 @@ if (process.argv.includes('--check')) { try { committed = readFileSync(resolve(root, OUT), 'utf8') } catch { - // Only an ENOENT (file not yet generated) is expected here; readFileSync of - // a present-but-unreadable file is not a state this repo produces. Either - // way the remedy is the same — regenerate — so we treat a read failure as - // "stale" and fall through to the failure branch below. + // Only an ENOENT (file not yet generated) is expected here; readFileSync of a + // present-but-unreadable file is not a state this repo produces. committed = null } if (committed === content) { diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 980ad19606..c1985d3ff8 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -1,45 +1,8 @@ /** - * Generate (and verify) the persistence log event catalog in - * docs/persistence-catalog.md. - * - * The catalog is the ON-DISK-vocabulary reference: every event type that can - * appear in a session's durable event log — every member of the - * merge-extensible `SessionEventMap`, across the owning declaration in - * `@deepseek-ai/dsh-session` and every plugin declaration merge. It complements - * the cordis events/services catalog (the live bus wiring — a log event is NOT - * a cordis event; it reaches listeners via the single `session/event` emit) and - * the core-data-structures session page (the `SessionEvent` envelope and - * derivation semantics): this page is the RECORDS a persisted log can contain. - * - * `tsx scripts/gen-persistence-catalog.ts` → write the catalog - * `tsx scripts/gen-persistence-catalog.ts --check` → exit 1 if the committed - * file is stale (CI / - * pre-push gate) - * - * Like its AST sibling `gen-cordis-catalog.ts` (and unlike the boot-based - * `gen-tool-catalog.ts`), this is a pure source pass: every log event is a - * string-literal-named property with a static type annotation, so the AST is - * the whole truth and a brand-new event (core or merged) appears in the next - * regenerate — an un-regenerated file fails `--check`. The walk enforces JSDoc - * COMPLETENESS on the whole vocabulary: every member carries description prose - * (it becomes the catalog entry), and an `@mode` tag on a member is a hard - * error — dispatch modes belong to cordis bus events, and a log event has none - * (see docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md). - * Structural holes are hard errors for the same reason: a member that is not a - * property signature with an explicit payload type, an `extends` clause on a - * declaration, a top-level `interface SessionEventMap` that is not the single - * exported declaration in the owning package, and a duplicate declaration of - * one event would each let something join (or impersonate) - * `keyof SessionEventMap` without a truthful catalog row. Violations aggregate - * into ONE error listing every offender. - * - * The surface/log-only badge is parsed from the `SurfaceEventType` union in the - * owning package (never hand-listed here), and every union member must name a - * collected event — a stale union member is a hard error. - * - * Payload fences use the ` ```ts persistence-catalog ` info string: - * doc-typecheck recognizes it and skips compilation (a bare payload fragment is - * not standalone-compilable), excluded from the opt-out ratio. + * Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and + * the owning `SurfaceEventType` union. Event declarations must be unique, + * explicitly typed, documented, and free of Cordis-only `@mode` tags. `--check` + * verifies the committed artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -56,14 +19,7 @@ const FENCE = 'ts persistence-catalog' /** The package whose module id plugin merges augment (`declare module '…'`). */ const SESSION_MODULE = '@deepseek-ai/dsh-session' -/** - * Cross-link map: a type name that appears in a payload → the - * core-data-structures page that documents it (path relative to OUT's folder). - * Hand-curated and catalog-owned, same policy as the cordis catalog's map: each - * name resolves to exactly one PRIMARY page. A payload type with no - * core-data-structures home (e.g. `HookDialect`, documented in its package) - * simply gets no link. - */ +/** Primary core-data-structures page for linked payload types. */ const LINK_MAP: Record = { CallId: 'core.md', ContentBlock: 'core.md', @@ -240,18 +196,7 @@ function packageNameFor(rel: string, scanRoot: string): string | null { } } -/** - * Walk every `SessionEventMap` declaration (the owning interface plus every - * plugin declaration merge) and extract its events, hard-erroring (aggregated) - * on any completeness violation: a member without description prose, an - * `@mode` tag (a category error — log events have no dispatch mode), a member - * that is not a property signature with an explicit payload type, a - * non-literal member name, an `extends` clause (inherited keys would join - * `keyof SessionEventMap` without a catalog row), a top-level declaration that - * is not the single exported one in the owning package, or the same event - * declared twice. - * `scanRoot` defaults to the repo root; tests pass a fixture dir. - */ +/** Collect and validate every `SessionEventMap` declaration merge. */ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { const entries: LogEventEntry[] = [] const violations: string[] = [] @@ -265,11 +210,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { for (const { decl, topLevel } of sessionEventMapDecls(sf)) { const declSrc = pointer(rel, sf, decl) if (topLevel) { - // The top-level form is the OWNING vocabulary, and it has exactly one - // home: the single EXPORTED declaration in the owning package. A - // same-named interface anywhere else — another package, a non-exported - // local, a second exported copy — is a different type that must not be - // catalogued as on-disk events. + // The top-level form is the OWNING vocabulary, and it has exactly one home: the single + // EXPORTED declaration in the owning package. const pkg = packageNameFor(rel, scanRoot) if (pkg !== SESSION_MODULE) { violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3405da6a8b..b9843cd9e6 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,36 +1,8 @@ /** - * Generate (and verify) the tool-schema catalog in docs/tool-catalog.md. - * - * The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin - * contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema - * `parameters` the model receives via the system-prompt assembly. It complements - * the cordis events/services catalog (the wiring a plugin author works against) - * and the core-data-structures catalog (the vocabulary those signatures move): - * this page is the TOOLS the agent is offered. - * - * `tsx scripts/gen-tool-catalog.ts` → write the catalog - * `tsx scripts/gen-tool-catalog.ts --check` → exit 1 if the committed file - * is stale (CI / pre-push gate) - * - * Why this generator BOOTS PLUGINS instead of parsing source (unlike its AST - * sibling `gen-cordis-catalog.ts`): a tool's schema is not statically knowable. - * `tool-todo` writes `enum: [...STATUSES]` (a runtime spread), descriptions are - * built by string concatenation, `tool-subagent`'s tool name is `config.toolName`, - * and an MCP plugin can register RAW JSON Schema without `defineTool` at all. The - * faithful source of truth is therefore the SHIPPED schema: mount each tool - * plugin on a real cordis Context and read `ctx.tools.schemas()` — exactly the - * `ToolSchema[]` the model is sent. See - * docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md. - * - * Booting sacrifices the AST pass's structural "nothing can be silently omitted" - * property (there is no source declaration to enumerate), so a COMPLETENESS GUARD - * restores it: the generator globs every `tool-*` package under `packages/` and - * hard-errors if any such package is absent from the boot manifest below. A new - * tool package fails the generator — and thus the freshness gate — until it is - * registered here, mirroring how a new event appears in the cordis regenerate. - * - * Schema blocks use a plain ` ```json ` fence: doc-typecheck only extracts `ts*` - * fences, so no BlockKind wiring is needed there. + * Generate `docs/tool-catalog.md` from schemas collected by booting each tool + * plugin. Runtime registration is the source of truth for computed schemas; + * the manifest is checked against every on-disk `tool-*` package. `--check` + * verifies the committed artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -63,19 +35,7 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -/** - * One tool-plugin package to boot. `mount` is a per-entry recipe (async): it - * plugs the injected seams the plugin's `apply` reads (an executor for - * `ctx.bash`, a provider for `ctx.subagents`) BEFORE the tool plugin itself. - * `SystemPrompt` + `ToolRegistry` are mounted for every entry by the caller - * (`ToolRegistry` injects `systemPrompt`), so `mount` only handles the extras. - * - * The recipe is irreducible policy — WHICH seams a given tool needs and with - * WHAT config is not derivable from the package layout — so it stays a hand- - * maintained closure. The `dir` field is what the completeness guard matches - * against the on-disk `tool-*` package glob, so a NEW tool package cannot be - * silently omitted (see the module doc). - */ +/** Tool package plus the non-default dependencies needed to boot it. */ interface ToolPackage { /** The npm package name, used as the catalog section heading. */ pkg: string @@ -174,9 +134,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'], async mount(ctx) { - // The tool injects `fs`; boot the local backend to satisfy it. The schemas - // do not depend on the policy plugin (an event gate that changes behavior, - // not tool shape), so the bare provider is enough to harvest them. + // The tool injects `fs`; boot the local backend to satisfy it. await ctx.plugin(LocalFileSystem) await ctx.plugin(ToolFs) }, @@ -249,10 +207,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - // The tools inject `web`; boot the seam plus one search and one fetch - // provider so both `web_search` and `web_fetch` register. The schemas do - // not depend on which provider backs the seam (or on it being available), - // so any registered provider is enough to harvest them. + // The tools inject `web`; boot the seam plus one search and one fetch provider so both + // `web_search` and `web_fetch` register. await ctx.plugin(WebService) await ctx.plugin(WebSearchExa) await ctx.plugin(WebFetchLocal) diff --git a/scripts/jsdoc.ts b/scripts/jsdoc.ts index d0e5771882..8311450195 100644 --- a/scripts/jsdoc.ts +++ b/scripts/jsdoc.ts @@ -1,14 +1,9 @@ /** - * Shared JSDoc parsing and completeness-check helpers for the documentation - * gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the - * events + `ctx.` service surface), the plugin config catalog generator - * (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the - * export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level - * export). One home for the mechanics so "documented" means the same thing on - * every gated surface: description prose ends at the first block tag; every - * checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return - * needs a non-empty `@returns`; a stale `@param` naming no real parameter - * errors. + * Shared JSDoc parsing and completeness-check helpers for the documentation gates: the cordis + * catalog generator (`scripts/gen-cordis-catalog.ts` — the events + `ctx.` service + * surface), the plugin config catalog generator (`scripts/gen-config-catalog.ts`, which + * renders the parsed prose), and the export-surface gate (`scripts/verify-export-jsdoc.ts` — + * every module-level export). */ import ts from 'typescript' @@ -30,14 +25,8 @@ export function rawJsDoc(text: string, node: ts.Node): string { export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' /** - * Parse a raw JSDoc block into description prose + the `@mode` tag (when - * present). Output obeys the repo's markdown conventions so the generated - * catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical - * line, and a `-` bullet list is preserved with each item on its own single - * line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. - * Description prose ends at the FIRST block tag (standard JSDoc semantics): - * tag lines and their continuation lines are never prose, so `@param` / - * `@returns` blocks are invisible to the rendered catalog. + * Parse a raw JSDoc block into description prose + the `@mode` tag (when present). + * * @param raw - the raw comment text including the JSDoc delimiters. * @returns the collapsed description prose plus the parsed `@mode` (or null). */ @@ -91,13 +80,9 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { } /** - * Parse the block tags of a raw JSDoc comment for the completeness checks: - * every `@param name — description` entry plus the `@returns` description. - * Standard JSDoc block-tag semantics — a tag's description runs across - * continuation lines until the next tag or a blank line, and the `-`/`—` - * separator after a param name is optional. `[name]` optional-brackets unwrap - * to `name`. Rendering never sees these: parseJsDoc stops prose at the first - * block tag. + * Parse the block tags of a raw JSDoc comment for the completeness checks: every `@param name + * — description` entry plus the `@returns` description. + * * @param raw - the raw comment text including the JSDoc delimiters. * @returns the `@param` name→description map plus the `@returns` description * (null when the tag is absent, '' when present but empty). @@ -134,17 +119,13 @@ export function parseTags(raw: string): { params: Map; returns: } /** - * Check the `@param` half of the completeness contract for one function-like - * declaration: every checkable parameter carries a non-empty `@param`, and no - * `@param` is stale. A binding-pattern parameter is a violation (it has no name - * for `@param` to match); an exempt parameter may be documented but its absence - * is never checked. Violations append to `violations` in place. + * Check that required parameter tags exist and no stale tag remains. * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`. - * @param surface - the surface noun for the binding-pattern message ("event", "service", "export"). + * @param surface - surface noun used in diagnostics. * @param parameters - the declaration's parameter list. * @param tags - the parsed `@param` name→description map from parseTags. - * @param sf - the source file (for rendering a binding pattern's text). - * @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`). + * @param sf - source file used to render binding patterns. + * @param isExempt - parameters that need no tag. * @param violations - the aggregate list violations append to. */ export function checkParams( @@ -174,11 +155,10 @@ export function checkParams( } /** - * Check the `@returns` half of the completeness contract: a non-`void` / - * `Promise` return needs a non-empty `@returns`, and the return type must - * be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void - * declaration `@returns` stays optional (resolution timing can be worth - * documenting), never required. Violations append to `violations` in place. + * Check the `@returns` half of the completeness contract: a non-`void` / `Promise` + * return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST + * walk cannot classify an inferred return. + * * @param where - the offender label violations open with. * @param typeNode - the declared return type annotation, or undefined when inferred. * @param returns - the parsed `@returns` description from parseTags (null when absent). diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 126943c8dd..9ec83d622a 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -7,10 +7,7 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' -// publint every harness package. Packages live at packages// -// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private -// upstream code and examples/ are not packages, both out of scope. Derived -// from the hierarchy so a new package needs no edit here. +// publint every harness package. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index f8cb1d763b..d3a0b4e3d5 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -1,19 +1,8 @@ /** - * Shared source of truth for the RFC index: the tree walker (structure rules) - * and the README table renderer. `gen-rfc-index.ts` writes the generated - * regions; `verify-rfc-classification.ts` checks structure and asserts the - * committed regions are fresh. Pure module — no side effects on import. - * - * The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)): - * every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the - * folder IS the label, and both sets are CLOSED — extending either means - * amending this module AND the README's Classification prose. - * - * The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections - * whose rows are derived from each RFC's path (lifecycle/class), H1 (title, - * with an optional `RFC: ` prefix stripped), and filename date, sorted by date - * then filename. The curated prose lives in README.md, which carries no index - * rows at all. + * Shared source of truth for the RFC index: the tree walker (structure rules) and the README + * table renderer. `gen-rfc-index.ts` writes the generated regions; + * `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh. + * Pure module — no side effects on import. */ import { readFileSync, readdirSync } from 'node:fs' diff --git a/scripts/verify-doc-budgets.ts b/scripts/verify-doc-budgets.ts index 1ace894410..7c7e6dc57d 100644 --- a/scripts/verify-doc-budgets.ts +++ b/scripts/verify-doc-budgets.ts @@ -1,30 +1,7 @@ /** - * Doc-sync gate: enforce word-count ceilings on the standing docs that accrete - * (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the - * architecture overview grow a paragraph per PR unless something pushes back; - * this gate is the pushback — when a ceiling is hit, the fix is to relocate or - * condense per the documentation standard, not to raise the ceiling. Raising a - * ceiling is allowed but is a deliberate, reviewable manifest diff that the PR - * description must justify. - * - * Scope is deliberately NARROW: only the files listed in - * scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs, - * and package READMEs are unbudgeted — length is legitimate there (a feature - * matrix is the right kind of long), and the standard governs them through - * review, not a ceiling. - * - * The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits - * at least 5% above the doc's current size (working headroom, so routine - * wording edits pass while real growth trips the gate) and ratchets DOWN, - * keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing - * fails the gate, so a rename cannot silently orphan its budget. - * - * Words are counted `wc -w` style over the whole file (whitespace-delimited - * tokens, fenced code included) so a ceiling is reproducible with standard - * tools. This is a checker, not a formatter: it reports and never rewrites. - * - * Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every - * budgeted doc's current count vs ceiling without failing). + * Enforce `wc -w`-style ceilings from `scripts/doc-budgets.manifest.json`. + * Missing files and invalid ceilings fail; `--list` reports current usage. + * Ceiling changes remain reviewable manifest edits. */ import { existsSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index 56be5140c8..bcbc93c7f8 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -1,29 +1,6 @@ /** - * Doc-sync gate: verify that doc references written in TypeScript COMMENTS - * resolve to a file that exists. Source comments cite docs by root-relative - * prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`, - * `docs/architecture.md § Where New Behavior Goes`. `verify-md-links` parses Markdown - * link AST and never sees these, so a doc rename or move could silently orphan - * a `.ts` comment that points at it. The RFC classification reorg - * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) - * is the motivating case: it moved every RFC under a `{class}/` folder, and - * several `.ts` doc comments cite RFC paths that changed. - * - * Detection is a token scan, NOT an AST walk: doc refs live in free prose inside - * comments, not in a structured form. We match `docs/.md` tokens and - * REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`, - * `docs/architecture.md § Where New Behavior Goes` — the section suffix is outside the - * token) is left alone rather than misread as a path. Each token is resolved - * ROOT-RELATIVE (the way the comments are written) and must exist on disk. This - * is checker, not fixer: it reports and never rewrites. - * - * Scope is repo-authored TypeScript under `packages/**` and `examples/**`, - * excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream - * source we do not own). The scan is purely textual, so it does not distinguish - * a token in a comment from one in a string literal — a `docs/….md` string in - * code is checked too, which is harmless (such a path should resolve anyway). - * - * Run: `tsx scripts/verify-doc-refs.ts`. + * Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The + * textual scan requires the extension and excludes built and vendored source. */ import { existsSync, globSync, readFileSync } from 'node:fs' @@ -38,12 +15,7 @@ const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts'] const isExcluded = (p: string): boolean => p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/') -/** - * Match a `docs/…​.md` reference token. The `.md` extension is required so a - * bare `docs/postmortem/0001` (no extension) does not register as a path. The - * character class stops at whitespace, backticks, parens, and the section sign, - * so trailing prose (`… .md § Where New Behavior Goes`) is not swallowed into the path. - */ +/** Root-relative Markdown path token, excluding trailing prose. */ const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g /** A broken doc reference: a root-relative `docs/….md` token with no file. */ diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 779888ab90..57def96573 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -1,77 +1,9 @@ /** - * Verify JSDoc completeness for EVERY module-level exported name of every - * non-vendored package (each `packages///src/` tree). This is the - * mechanical form of the AGENTS.md rule "every export has a JSDoc explaining - * semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`, - * which owns `interface Events` members and `ctx.` service classes) to - * the whole export surface; the parsing + check helpers are shared via - * `scripts/jsdoc.ts` so "documented" means the same thing on both. - * - * `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender - * - * The contract, per exported declaration kind: - * - * - Every exported name needs JSDoc with non-empty description prose (prose - * ends at the first block tag, standard JSDoc semantics). - * - A function-like export (function declaration, a const with a function - * initializer or an INLINE callable annotation, or a non-identifier - * function default export) additionally needs a non-empty `@param` per - * parameter (`this` receiver annotations exempt; a stale `@param` errors) - * and a non-empty `@returns` unless the return type is `void` / - * `Promise`. Wrapper expressions (parentheses, `as` / `satisfies` - * casts, non-null assertions) are peeled before classifying. The walk - * classifies returns syntactically, so the return type must be ANNOTATED — - * except a const whose declarator is annotated with a NAMED type (e.g. - * `export const f: Handler = …`), where that type's own declaration owns - * the signature contract and `@returns` stays optional; an inline - * `(x: T) => U` annotation or single-call-signature literal is the surface - * signature itself and gets the full contract, and a literal mixing - * call/construct signatures with anything else is refused (extract a named - * type). - * - An exported class needs class-level JSDoc; its public methods (static - * included — they are reachable on the exported name) follow the function - * contract, and public properties and accessors need description prose (on - * a get/set pair the getter's doc covers both). A member declared by an - * `extends`/`implements` heritage type is EXEMPT — the seam declaration is - * the doc's one home, the IDE inherits it, and re-documenting every - * implementation invites drift — UNLESS the override grows surface the - * base never documented: a protected-only base member does not exempt a - * public override, parameters the base never names keep their `@param` - * duty, and a concrete result above a void base return keeps its - * `@returns` duty. Heritage members (and classifying an unannotated - * override's inferred return above a void base) are the questions the walk - * asks the TYPE CHECKER; everything else is pure AST. - * Constructors are exempt like the cordis gate's: plugin classes are - * framework-constructed, and the class doc owns the story. - * - Exported interfaces, type aliases, enums: description prose on the - * declaration (member-level docs stay review's job; the highest-value - * member surface — seam service classes — is already under the cordis - * gate). - * - An exported namespace recurses (its exported members are package - * surface; in an ambient `declare` namespace every member exports - * implicitly); the namespace itself needs prose only when it does not - * merge with an already-documented same-name declaration (the - * Config-namespace idiom documents the class/function once, not twice). - * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject` - * / `reusable` / `Config` consts and the `apply` entry, plus the same - * slots as statics on a plugin class. Their shape is fixed by the - * framework, so a doc would restate the protocol — the module doc comment - * and the `interface Config` carry the plugin's real semantics. (These - * names are reserved by cordis convention; documenting one anyway is - * allowed, only absence goes unchecked.) - * - Overload groups: each overload signature carries its own docs; the - * implementation signature is exempt (callers never see it). - * - Skipped: `declare module` / `declare global` augmentation bodies (the - * cordis gate's turf; an augmentation is not an export of the package) and - * re-export statements with a module specifier (`export … from`) — the - * defining module is walked on its own, and external definitions are not - * ours to document. An `export import X = N.member` alias documents - * ITSELF, and only prose-only target kinds are gate-supported: a callable, - * class, or namespace target carries signature/member contracts the alias - * cannot hold and is refused (export the declaration directly). - * - Everything else fails CLOSED: `export =` is refused outright, and an - * exported statement kind the dispatch does not recognize is itself a - * violation, so no export form can pass unchecked by omission. + * Enforce JSDoc on every non-vendored package export. Functions and public + * class methods require parameter and non-void return documentation; exported + * declarations require description prose. Framework protocol slots, + * constructors, inherited members, augmentations, and source re-exports keep + * their documentation at the declaring contract. Unknown export forms fail. */ import { existsSync, globSync } from 'node:fs' @@ -162,29 +94,12 @@ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'r } /** - * The heritage-member exemption for one class member. When the member's name - * is declared by an `extends`/`implements` heritage type, the seam declaration - * is the doc's one home (the IDE inherits it on hover) and the member needs no - * doc of its own — EXCEPT where the override grows public surface the base - * never documented: a base member that is protected on every declaration does - * not exempt a public override (consumers could not call it before); - * parameters the base never names keep their own `@param` duty (the caller - * reads the seam doc, which cannot describe them; an underscore-prefixed - * rename of a base parameter — the deliberately-unused marker — is the same - * parameter, not new surface); and a void base return carried no `@returns` - * duty, so an override returning a concrete result documents it itself. - * Static members are looked up on the base CONSTRUCTOR type (only an - * `extends` expression has one; an unresolvable or interface expression - * yields no property and therefore no exemption). + * Find inherited documentation for a class member without exempting newly public surface. * @param cls - the class whose heritage to search. * @param name - the member name to look up. * @param staticSide - whether to search the constructor side instead of the instance side. * @param checker - the program's type checker. - * @returns null when no exemption applies; otherwise the parameter names the - * base declarations carry (`baseParams: null` when not syntactically - * recoverable — a complex heritage type — exempting all parameters) plus - * whether every recoverable base return annotation is `void`-like - * (`baseVoidReturn: null` when none is recoverable, exempting the result). + * @returns inherited parameter and return coverage, or `null` when none applies. */ function heritageExemption( cls: ts.ClassDeclaration, @@ -326,11 +241,8 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf, p => thisReceiver(p) || inBase(p), w.violations) } - // A void base return carried no @returns duty, so an override growing - // a concrete result documents it itself. An annotated override runs - // the standard check; an inferred one is classified by the checker - // (this branch is already the checker's domain), so a faithful void - // override stays exempt without a boilerplate annotation. + // A void base return carried no @returns duty, so an override growing a concrete result + // documents it itself. if (exemption.baseVoidReturn === true) { if (m.type !== undefined) { checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations) @@ -354,20 +266,14 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { } /** - * Check one exported declaration statement, dispatching on its kind. Any - * exported statement kind the dispatch does not recognize is a violation - * (fail closed), so no export form can pass unchecked by omission. - * @param stmt - the exported statement (export modifier or export-list target). - * @param prefix - the namespace qualification for surface names ('' at top level). - * @param overloadSigs - names in this scope declared as bodyless function overload signatures. - * @param byName - this scope's named declarations (for namespace/sibling-merge lookups). - * @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly. - * @param w - the walk state violations append to. - * @param only - for a multi-declarator variable statement reached through an - * export list (or a default-export identifier), the declarator names that - * are actually exported; `null` means the whole statement is surface - * (direct `export` modifier or ambient scope). Non-variable statements - * declare exactly one name, so the filter never applies to them. + * Check one exported declaration. + * @param stmt - exported statement. + * @param prefix - namespace qualifier. + * @param overloadSigs - bodyless overload names. + * @param byName - declarations keyed by name. + * @param ambient - whether exports are implicit. + * @param w - walk state. + * @param only - selected declarators, or all. */ function checkDecl( stmt: ts.Statement, @@ -455,13 +361,9 @@ function checkDecl( } if (ts.isImportEqualsDeclaration(stmt)) { const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}` - // An alias is a distinct exported name whose target may be a non-exported - // namespace member no walk ever visits, so it documents ITSELF — which - // matches the gate's strength only for prose-only target kinds. A - // callable, class, or namespace target carries signature or member - // contracts the alias prose cannot hold: refuse those (fail closed) and - // demand the declaration be exported directly. An unresolvable target is - // refused for the same reason. + // An alias is a distinct exported name whose target may be a non-exported namespace member + // no walk ever visits, so it documents ITSELF — which matches the gate's strength only for + // prose-only target kinds. const sym = w.checker.getSymbolAtLocation(stmt.name) const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule @@ -511,17 +413,7 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk } } } - // Two-phase dispatch. Phase one accumulates WHICH statements are surface - // and, for a variable statement reached by name (an export list or a - // default-export identifier), which of its declarators the exports actually - // name — `null` marks the whole statement as surface (a direct `export` - // modifier, or an ambient scope). Requests for the same statement merge: - // `null` absorbs any name set, and name sets union, so - // `export { a }; export { b }` over one `const a = …, b = …` checks both - // declarators while a never-exported sibling stays out of the surface. - // Phase two runs each surfaced statement exactly once. (Checking a - // statement eagerly per request would either re-check on the second list or - // — deduplicated — silently drop the second list's declarators.) + // Two-phase dispatch. const requested = new Map | null>() const request = (stmt: ts.Statement, name: string | null): void => { const prior = requested.get(stmt) @@ -575,14 +467,8 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk } /** - * Compiler options for the walk's program. The real repo hands over its - * tsconfig.base.json (whose `paths` map resolves cross-package imports to - * source, so heritage-member lookups see seam types); a fixture root without - * one gets `noLib` + no `@types` — fixtures are single-file and - * self-contained, nothing in the walk resolves a lib symbol, and default-lib - * parsing is ~99% of per-program cost (it made the fixture spec time out - * under CI coverage instrumentation). Emit-side options are stripped: the - * walk never emits or asks for diagnostics, it only binds types on demand. + * Compiler options for the walk's program. + * * @param scanRoot - the root being scanned. * @returns compiler options for ts.createProgram. */ diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index d3e80e285e..6ff14e93df 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -1,35 +1,7 @@ /** - * Doc-sync gate: verify that every relative Markdown cross-link resolves to a - * file that exists. Docs in this repo link to each other by relative path - * (`[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`); - * a rename or a move silently breaks those links, and nothing caught it before - * review. The RFC tree reorganization (one `docs/rfc/` with proposed/ - * implemented/ rejected/ subfolders, every file renamed to a dated slug) is the - * motivating case: ~40 inter-doc links were rewritten by hand, and a single - * fat-fingered path would have shipped a dead link. - * - * Detection is AST-based, mirroring verify-md-wrap: parse each file with - * mdast-util-from-markdown + GFM, then walk every `link`, `image`, and - * `definition` node. A target is checked when it is a RELATIVE path; these are - * skipped because they are not ours to verify: - * - absolute URLs with a scheme (`https:`, `http:`, `mailto:`, …), - * - protocol-relative URLs (`//host/path`), - * - root-absolute paths (`/foo` — no stable base in a repo checkout), - * - pure in-page anchors (`#section`). - * For a relative target the `#fragment` and `?query` are stripped, the path is - * resolved against the linking file's directory, and the result must exist on - * disk. This is checker, not fixer: it reports and never rewrites. - * - * Scope is the other doc-sync gates' set plus example Markdown, AGENTS.md - * files in those checked trees, AND the repo-authored agent-skill Markdown under - * `.agents/skills/` — those skill files cross-link into the docs tree (e.g. the - * dsh-code-review skill cites the RFC index), so a rename must not silently - * break them either: README.md, docs/** /*.md, packages/* /README.md, - * examples/** /*.md, AGENTS.md, packages/AGENTS.md, .agents/skills/** /*.md. - * The root, packages/, and examples/ CLAUDE.md files are symlinks to the - * AGENTS.md files, so they are deduped by real path. - * - * Run: `tsx scripts/verify-md-links.ts`. + * Verify that relative Markdown links, images, and definitions resolve. URL, + * root-absolute, and in-page targets are excluded; query strings and fragments + * do not affect the filesystem check. Symlinked instruction files are deduped. */ import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs' @@ -41,10 +13,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') -/** - * Files to check: doc-typecheck's scope, example Markdown, the AGENTS.md pair, - * and repo-authored agent-skill Markdown. - */ +/** Repo-authored Markdown checked for relative links. */ const PATTERNS = [ 'README.md', 'README.zh.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 3143ee5617..9d9333ff61 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -1,30 +1,7 @@ /** - * Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention - * (docs/AGENTS.md § Writing rules) — prose paragraphs are written as - * one physical line per paragraph and the editor soft-wraps. A hard-wrapped - * paragraph (a one-word edit reflows and re-diffs the whole block) is a defect - * this script catches before review. - * - * Detection is AST-based: we parse each file with mdast-util-from-markdown (the - * CommonMark parser behind remark) plus the GFM extension, then flag any - * `paragraph` node whose source span covers more than one line. The parser owns - * all the structure that legitimately occupies multiple lines — fenced code - * (any fence length), tables, list items, blockquotes, HTML blocks, headings, - * thematic breaks, link-reference definitions — so a hard wrap is simply "a - * paragraph node that starts and ends on different lines." This is checker, not - * formatter: it reports and never rewrites, so it introduces zero cosmetic - * churn (no emphasis-marker or table-delimiter normalization). - * - * A wrapped paragraph inside a list item or blockquote is still a `paragraph` - * node, so those are caught too. Scope mirrors doc-typecheck plus the two - * AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself - * lives there), plus generated system-prompt Markdown goldens: README.md, - * docs/** /*.md, packages/* /*.md, examples/** /system-prompt.golden.md, - * packages/** /system-prompt.golden.md, AGENTS.md, packages/AGENTS.md. The root - * and packages/ CLAUDE.md are symlinks to the AGENTS.md files, so they are - * deduped by real path. - * - * Run: `tsx scripts/verify-md-wrap.ts`. + * Reject Markdown prose paragraphs spanning multiple physical lines. The GFM + * AST distinguishes paragraphs from multiline structural nodes; symlinked + * instruction files are deduped. */ import { globSync, readFileSync, realpathSync } from 'node:fs' @@ -71,8 +48,7 @@ function findViolations(absPath: string): Violation[] { const firstLine = source.split('\n')[start.line - 1] ?? '' out.push({ file, line: start.line, text: firstLine.trim() }) } - // A paragraph's children are inline (text/emphasis/…); no nested - // paragraphs to find, so don't descend. + // Paragraph children are inline, so no further paragraph can be nested. return } if ('children' in node) { diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 5d2d91982b..36e7f0e1b3 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -1,42 +1,8 @@ /** - * Doc-sync gate: catch DRIFTED `packages/` references — a path to a - * package that has MOVED, written as prose in Markdown or in a TypeScript - * comment/string. Docs and comments cite package locations by root-relative - * path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`); - * `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs` - * only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick - * prose or a code comment goes unchecked. The package-hierarchy reorg is the - * motivating case: it moved every package under a `{group}/` folder, so a stale - * `packages/tools` (now `packages/core/tools`) reads fine to a human but points - * at nothing. - * - * The check is drift-scoped, NOT a blanket existence test: a broken - * `packages/` token is a violation ONLY when one of its path segments is - * the directory name of a package that actually exists on disk — i.e. the - * package is real and the path is merely stale. A token naming a package that - * exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an - * illustrative `packages//` skeleton) is left alone: this gate reports - * MOVED paths, not hypothetical or future ones, so it applies uniformly to - * proposed/implemented/rejected docs without per-lifecycle exclusions. This is - * checker, not fixer: it reports and never rewrites. - * - * Detection is a token scan, NOT an AST walk: package refs live in free prose, - * backticks, and comments. We match `packages/` tokens whose path is made - * of plain path characters, so a glob, a ``, or a `{brace,expansion}` - * terminates the match before those chars and is never probed. - * - * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown - * across README/docs/packages/AGENTS, and `.ts` under packages/** and - * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source). - * A reference to a package's build OUTPUT (`packages///lib/…`, - * e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also - * skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this - * gate, so flagging it would be a false positive on a path that is correct but - * not yet on disk. That skip is scoped to a REAL package root: a stale - * group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not - * exist — exactly the moved-package drift this gate catches). - * - * Run: `tsx scripts/verify-package-paths.ts`. + * Find stale root-relative `packages/...` references in repo-authored prose and + * TypeScript. A missing path is reported only when it names a real package leaf; + * globs, placeholders, hypothetical packages, and unbuilt `lib/` output are + * outside the check. */ import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs' @@ -117,14 +83,9 @@ function findViolations(absPath: string): Violation[] { // class may have swallowed (`packages/core/tools.` / `…/tools/`). const ref = m[0].replace(/[./]+$/, '') if (existsSync(resolve(root, ref))) continue - // A reference INTO a package's built `lib/` is a build OUTPUT, not an - // authored-source location: it does not exist until `pnpm run build` emits - // it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when - // the `packages//` ROOT it sits under is real and on disk, so - // `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is - // exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the - // exact moved-package drift this gate exists to catch) still flags. A bare - // `lib` segment is not a blanket escape hatch. + // A reference INTO a package's built `lib/` is a build OUTPUT, not an authored-source + // location: it does not exist until `pnpm run build` emits it, and CI runs this gate + // before the build step. const parts = ref.split('/') const libAt = parts.indexOf('lib') if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 2e270c17f8..9762b11340 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,31 +1,7 @@ /** - * Doc-sync gate: enforce the RFC classification scheme - * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) - * and the freshness of the generated index - * ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)). - * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the - * folder IS the label. This gate is the machine source of truth for the closed - * class set and keeps the generated index honest. - * - * Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker - * and renderer): - * - * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder - * from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1. - * A loose `.md` directly under a lifecycle root (other than the - * README/AGENTS allowlist) fails; an unknown class folder fails; a stray - * file at an unexpected depth fails. This is what makes the set CLOSED: a - * new class folder can't appear without amending CLASSES (and the README's - * Classification section, per the RFC). - * 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render - * from the tree, so every RFC is listed exactly once, under the heading - * matching its path, with its H1 title and filename date. The fix for a - * stale index is `pnpm run gen-rfc-index`, never a hand edit. This is - * checker, not fixer: it reports and never rewrites. - * 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no - * index-shaped table rows; the list lives only in the generated INDEX.md. - * - * Run: `tsx scripts/verify-rfc-classification.ts`. + * Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the + * generated index and reject index rows in the curated README. Structural rules + * and rendering are shared with `rfc-index.ts`. */ import { readFileSync } from 'node:fs' diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts index 99347e4764..123a6bdd30 100644 --- a/scripts/verify-rfc-format.ts +++ b/scripts/verify-rfc-format.ts @@ -1,31 +1,7 @@ /** - * Doc-sync gate: enforce the RFC in-file format - * ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in - * [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)). - * The classification gate owns WHERE a file sits and how it is named; this gate - * owns what is INSIDE: the header block, the per-lifecycle body skeleton, and - * the Alternatives-considered mandate. - * - * Per English RFC (`.zh.md` counterparts are the pairing gate's concern): - * - * 1. HEADER — line 1 is `# RFC: `, line 2 blank, line 3 the one - * `Status:` line in the file, line 4 blank. The status is the dateless enum - * matching the lifecycle folder: `Status: proposed`, `Status: implemented`, - * or `Status: rejected — <reason>`. - * 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's - * required sections are present under their canonical names (`proposed/`: - * Proposal, Acceptance criteria, Risks; `implemented/`: Decision, - * Consequences; `rejected/`: Proposal); `implemented/` must not carry the - * proposal-era headings (Proposal, Plan, Migration plan, Acceptance - * criteria) that the docs standard's slop checklist outlaws there. - * 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a - * pre-format RFC (dated before the format landed) carrying the exact - * grandfather comment instead. Carrying both, or grandfathering a - * post-format RFC, fails. - * 4. DEBT MARKER — the retired legacy-format debt comment may not reappear. - * - * Checker, not fixer: it reports and never rewrites. - * Run: `tsx scripts/verify-rfc-format.ts`. + * Enforce RFC headers, lifecycle-specific sections, alternatives, and retired + * marker rules. Classification and filenames belong to the sibling tree gate; + * translation structure belongs to the pairing gate. */ import { readFileSync } from 'node:fs' @@ -65,9 +41,7 @@ for (const rfc of rfcs) { errors.push(`format: ${rfc.rel} — ${msg}`) } const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n') - // Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a - // status line, a banned heading, or the grandfather comment inside a fence - // (the README's own format section does), and only real prose counts. + // Format tokens inside fenced examples are not document structure. let inFence = false const prose = lines.filter((l) => { if (l.startsWith('```')) { diff --git a/scripts/verify-scoped-dispatch.ts b/scripts/verify-scoped-dispatch.ts index 214d55f7dc..1cbc4e3f81 100644 --- a/scripts/verify-scoped-dispatch.ts +++ b/scripts/verify-scoped-dispatch.ts @@ -1,19 +1,9 @@ /** - * Scoped-dispatch drift gate: the set of scope-filtered events is declared in - * TWO places that must never diverge — the dev-invariants runtime table (the - * `scopedSubject` map in `packages/support/invariants/src/index.ts`, which - * enforces carriers at dispatch time) and the event declarations' JSDoc (the - * "Scope-filtered dispatch" sentence rendered into the events catalog, which - * tells plugin authors what a scoped listener will and won't hear). An event - * added to one side without the other either silently escapes runtime - * enforcement or documents filtering that never happens; this gate fails the - * build instead. - * - * Sources of truth: the invariant table is parsed from the invariants source; - * the documented set is parsed from every `declare module 'cordis'` Events - * JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject - * notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) - * are deliberately unfiltered and must appear in NEITHER set. + * Scoped-dispatch drift gate: the set of scope-filtered events is declared in TWO places that + * must never diverge — the dev-invariants runtime table (the `scopedSubject` map in + * `packages/support/invariants/src/index.ts`, which enforces carriers at dispatch time) and + * the event declarations' JSDoc (the "Scope-filtered dispatch" sentence rendered into the + * events catalog, which tells plugin authors what a scoped listener will and won't hear). */ import { globSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 3d80572e7c..7fd5b64ada 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -1,46 +1,8 @@ /** - * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md). - * English and Chinese carry EQUAL authority — either language may be authored - * first — so consistency is recorded per pair in a sidecar metadata file, - * `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last - * time a human confirmed the two say the same thing: - * - * foo.md: <40-hex blob hash> - * foo.zh.md: <40-hex blob hash> - * - * The gate checks, mechanically, the checkable half of the contract: - * - * 1. Every file in the manifest's `required` list has a COMPLETE pair - * (the enforcement frontier — grows batch by batch). - * 2. Every pair that exists at all is complete and consistent: all three - * files present (a `.zh.md` or a `.i18n.yaml` without its counterparts - * is an error — pairs merge whole, never half), each side's current - * blob hash equals the recorded one (an edit to EITHER side without a - * re-confirmed counterpart goes red), both sides carry the language - * switcher, and the structural signatures match one to one — heading - * depths in order, fenced code blocks VERBATIM (info string + content), - * table column counts, list kinds, and every link target except the - * switcher itself. - * 3. `excluded` files (generated docs, agent instructions, the bilingual - * terminology table) have no `.zh.md` and no `.i18n.yaml` at all. - * - * What it deliberately does NOT check is translation quality or which side - * is "right": a green gate means the pair was confirmed consistent at these - * exact contents, not that the confirmation was sound — accuracy, - * terminology, and tone are the human reviewer's half of the contract - * (docs/i18n/translation-rules.md). - * - * Blob hashes, not commit hashes, so a pair edited in the same PR verifies - * without any history lookup: consistency is a pure content comparison, - * computed here directly (sha1 of `blob <size>\0<content>`) without spawning - * git. The recorded hash also recovers the last-confirmed text of either - * side (`git cat-file -p <hash>`) for diff-based minimal updates. - * - * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to - * print the pairing state of every in-scope document as a work list (always - * exits 0), or with `--write` to (re)record both hashes for every complete - * pair after you have brought the two sides back in line (the resulting - * yaml diff is the reviewable act of confirming consistency). + * Enforce complete English/Chinese pairs, matching structure, and recorded git + * blob hashes under the bilingual manifest. `--list` reports state; `--write` + * records both sides after human review. Translation quality remains a review + * responsibility. */ import { createHash } from 'node:crypto' diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 85ccd642d9..9a4dd75fba 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -1,25 +1,7 @@ /** - * Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a - * VERBATIM copy of the source type definition it documents. - * - * The core-data-structures docs paste real type definitions so a reader sees - * the exact shape. A paste drifts the moment source changes — this script is - * the drift guard. For each block it extracts the documented symbol's - * declaration from source via the TypeScript compiler API, whitespace- - * normalizes both the source text and the block, and asserts they are equal. - * - * Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`), - * NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script - * enforces a 1:1 correspondence — every type-equiv block in the docs has - * exactly one manifest entry (keyed by doc + declared symbol), and every - * manifest entry resolves to exactly one block. An orphan on either side fails, - * so a block can never be silently unchecked and an entry can never rot. - * - * doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it - * (it is not standalone-compilable and is not counted in the opt-out ratio); - * the two scripts share the fence, this one owns the verification. - * - * Run: `tsx scripts/verify-type-equiv.ts`. + * Verify every `ts type-equiv` block against the source symbol named by the + * manifest. Blocks and entries have a one-to-one relationship; comparison + * ignores comments and whitespace but preserves declaration structure. */ import { globSync, readFileSync, existsSync } from 'node:fs' @@ -28,13 +10,7 @@ import ts from 'typescript' const root = resolve(import.meta.dirname, '..') -/** - * Markdown globs scanned for ` ```ts type-equiv ` blocks — the SAME scope - * doc-typecheck uses. Scanning every doc (not only the docs the manifest names) - * is what makes the 1:1 guarantee real in both directions: a type-equiv block - * added to a doc with NO manifest entry is still discovered here and reported as - * an orphan, instead of being silently skipped. - */ +/** Markdown scope shared with doc-typecheck. */ const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] /** One manifest entry: a documented type-equiv block and its source symbol. */ @@ -58,12 +34,7 @@ interface EquivBlock { code: string } -/** Collapse a declaration to its structural form for comparison: drop comments - * (block + line), then collapse all whitespace runs to single spaces. This lets - * a doc block show a CLEAN definition (without source's verbose inline JSDoc) - * while still guaranteeing the field shapes match — drift in a field name or - * type fails; a reworded inline comment does not. Adequate for our own type - * source (no string literal contains `//` or `/* *​/`); not a general tokenizer. */ +/** Remove comments and normalize whitespace for structural comparison. */ function normalize(code: string): string { return code .replace(/\/\*[\s\S]*?\*\//g, '') @@ -72,8 +43,7 @@ function normalize(code: string): string { .trim() } -/** Strip a leading `export ` / `export default ` modifier — the doc block shows - * the bare declaration, the source carries the export modifier. */ +/** Strip source-only export modifiers. */ function stripExport(code: string): string { return code.replace(/^export\s+(default\s+)?/, '') } diff --git a/tsdown.config.ts b/tsdown.config.ts index 9479c5c91d..e239188a7d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -10,10 +10,8 @@ import { defineConfig } from 'tsdown' * (schemastery: dual ESM+CJS; logger-console: extra browser entry). */ export default defineConfig({ - // Explicit globs: `workspace: true` would also discover examples (any - // package.json), but only vendor and the packages hierarchy are pnpm - // workspaces. The Landlock launcher platform packages ship a prebuilt - // native binary and no JavaScript — nothing to bundle. + // Explicit globs: `workspace: true` would also discover examples (any package.json), but only + // vendor and the packages hierarchy are pnpm workspaces. workspace: ['vendor/*', 'packages/*/*'], entry: ['lib/types/index.js'], outDir: 'lib', diff --git a/vitest.config.ts b/vitest.config.ts index 11d1454b08..b55b6b25d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,20 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ // Vite ≥8 warns that this plugin can be replaced by the native (experimental) - // `resolve.tsconfigPaths: true`. It cannot — keep the plugin. Tests run - // unbuilt (see AGENTS.md): bare workspace names like `cordis` or - // `@deepseek-ai/dsh-llm` must resolve to src/, and that mapping comes from - // the root tsconfig.json paths map. The native option is a bare boolean: - // for each - // importing file it discovers the NEAREST tsconfig.json and applies that - // file's own `paths`. Every workspace under packages/* and vendor/* has its - // own tsconfig.json without `paths`, so native resolution maps nothing, - // falls through to package.json exports (lib/, absent until `pnpm run build`), - // and every test file fails to import (verified on vite 8.0.16 / - // vitest 4.1.8). Making it work would mean copying the paths map into all - // 15 workspace tsconfigs — including vendor/* ones, which are pinned - // upstream copies (vendor/README.md). The plugin's `projects` option - // instead applies the one root map to every importer. + // `resolve.tsconfigPaths: true`. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], @@ -26,16 +13,7 @@ export default defineConfig({ // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). include: ['packages/*/*/src/**/*.ts'], - // Types-only files carry no executable code. `bin.ts` files are - // self-executing CLI entrypoints (a top-level `await main()`): a spec - // can't import one without booting it, so they are driven by the keyless - // Loader-path smoke (a real subprocess) instead of the in-process unit - // suite — the same reason `examples/start.ts` sat out of coverage scope. - // `worker.ts` files are the same class as bin.ts: self-executing - // worker-thread entrypoints that only ever run inside a spawned isolate - // the v8 provider cannot observe. They stay thin glue over in-process- - // tested logic (bootstrap.ts) and are pinned by real-worker integration - // tests. + // Self-executing bins and workers are covered by subprocess tests outside v8 collection. exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts'], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 57930db57a..28540dc605 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -2,17 +2,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' // Real-API end-to-end tests: `pnpm run test:e2e`, file pattern *.e2e.ts. -// Separate from the default suite (`pnpm run test`, *.spec.ts) on purpose — -// these hit the live DeepSeek API, spend tokens, and need a key. -// -// Secrets: tests gate themselves with -// `describe.skipIf(!process.env.DEEPSEEK_API_KEY)`, so the suite passes -// (all-skipped) without credentials. The keyless CI workflow relies on that; -// the real-API workflow preflights the secret and fails loudly if it is absent. -// Put the key in the environment or in a gitignored `.env` at the repo root: -// -// DEEPSEEK_API_KEY=sk-… -// DEEPSEEK_BASE_URL=https://… # optional, defaults to the public API try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index d0a8ce6a54..34c7de7742 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -2,19 +2,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' // Snapshot tests: `pnpm run test:snapshot`, file pattern *.snapshot.ts. -// REPLAY by default — they boot the real acp-agent subprocess against a -// recorded session JSONL fixture (no API key, no network) and diff the -// normalized stdout transcript + re-persisted log against committed goldens. -// `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the -// fixtures against the real API and refreshes the goldens. -// `pnpm run test:snapshot:refresh` (DSH_SNAPSHOT=refresh) stays keyless: it -// replays the committed model scripts and writes the current stdout/log goldens -// without calling the live LLM. -// -// Replay loads no .env (it must never reach the network — a recorded fixture -// drives the model), and refresh uses that same keyless replay path. Record -// reads DEEPSEEK_API_KEY from the env or a gitignored repo-root .env, so a -// contributor with a key only in .env can still record. if (process.env.DSH_SNAPSHOT === 'record') { try { process.loadEnvFile(new URL('.env', import.meta.url).pathname)