diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 51ddb872c3..3502e4454a 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,53 +5,36 @@ 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. +**This skill is guidance, not a complete checklist.** Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. 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. +- [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. +- [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 the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; 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..6ad8902d66 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 placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect. ## Sources of truth (read, don't re-summarize) @@ -25,16 +25,17 @@ Run the placement test in the standard's taxonomy table, then check the constrai ## Auditing the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first: +The audit is a hunt for the standard's slop checklist, cheapest probes first. Establish the PR's current base first; after a retarget or base merge, repeat the audit for prose introduced by the new base rather than relying on the earlier result. -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). +1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. +2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. +3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, rejected local alternatives, and the same rationale repeated beside sibling methods. 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 catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference. +6. In `implemented/` RFCs, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps. +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/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md new file mode 100644 index 0000000000..b4cc300f14 --- /dev/null +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -0,0 +1,75 @@ +--- +name: dsh-prose-standard +description: Use when writing, reviewing, restoring, trimming, or auditing prose in the deepseek-harness repo, including deciding where documentation or comments are required across Markdown, JSDoc, code and test comments, prompts, descriptions, diagnostics, and CLI or UI strings. +--- + +# DeepSeek Harness Prose Standard + +Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script. + +## Inputs and exclusions + +Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview. + +Accept `mode: automatic | interactive`; default to `automatic`. Enter interactive mode only when the user explicitly requests questions or calibration. + +`mode` controls questions, not write authority. Review and audit tasks report findings without editing; explicitly requested write, fix, or trim tasks apply clear changes. + +Always exclude `vendor/` from discovery, review, and edits, even when the requested scope is the whole repository. Do not follow a symlink into it. Put exclusions after inclusion globs so a later include cannot re-admit it: for example, end ripgrep commands with `--glob '!vendor/**'`, and give Git commands an explicit `:(exclude)vendor/**` pathspec. If the requested scope contains only `vendor/`, report that no eligible files remain. + +Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Update the counterpart minimally and re-record the pair. + +## Preserve the complete proposition + +Before editing, identify every proposition in the passage. Preserve each relevant: + +- actor and action; +- condition, timing, and ordering; +- modality such as must, may, or never; +- negative guarantee and exception; +- ownership, side effect, failure mode, and consequence. + +Remove adjectives, repetition, and narration only when every factual clause survives and the result is clearer. A smaller word count alone is not an improvement. + +Keep a complete local contract at the point of use: behavior, failure, ownership, and consequence that a caller or maintainer needs there. Aggressively link to the owning document for architecture, rationale, algorithms, history, or extended examples. One explanation has one home; essential contract facts may repeat locally. + +Keep non-obvious rationale when omitting it could plausibly cause misuse or an incorrect simplification. Otherwise state the consequence and link the rationale home. + +## Required coverage by prose surface + +This is not a one-way shortening pass. Add or restore prose when code, types, and structure do not communicate a required contract below. Do not add a comment when those facts are already obvious locally. + +- **Public JSDoc:** document caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability. +- **Internal comments:** orient non-local structure and obviously complicated local structure, including invariants, race ordering, ownership, security boundaries, and surprising failure behavior. Delete control-flow narration and code restatement. +- **Module comments:** state the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation. +- **Tests:** explain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories. +- **Cookbooks:** include prerequisites, required actions, the real entry path, observable verification, and concise warnings. +- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README contract](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- **RFCs:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps. Implemented RFCs state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. +- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. +- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. +- **Examples and configuration comments:** explain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. +- **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies. +- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. + +Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. + +## Workflow + +1. Confirm the scope, mode, current branch or PR base, and applicable `AGENTS.md` files. Do not inspect unrelated branches. +2. Read [the documentation standard](../../../docs/AGENTS.md) and the owning code or document before judging a passage. For calibration or unfamiliar cases, read [the distilled examples](references/examples.md). +3. Inspect the requested scope, not only the largest files. Use searches and word counts to find candidates, then judge passages semantically. +4. Classify each candidate as keep, add, trim, restore, restructure, or defer. Apply clear changes only when the task authorizes edits; do not manufacture edits to satisfy a deletion target. +5. Update the owner before derivative artifacts. Re-check analogous passages after learning a new rule. +6. Run the narrow relevant checks, documentation gates, `git diff --check`, and behavior tests for visible strings. Verify the final diff contains no `vendor/` path and report any accidental vendor match rather than claiming a clean exclusion history. +7. Report the inspected scope, clear changes, deliberate keeps, deferred cases, and checks actually run. + +## Borderline decisions + +A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A new prose shape with one contract-preserving answer is not borderline. + +In automatic mode, apply clear edits when authorized and report genuine borderline cases without asking questions. Do not weaken a proposition to make progress. + +In interactive mode, group analogous passages under the governing principle. Present two or three viable versions, recommend one, and state the factual or structural difference. Do not offer inferior distractors. Use the user's requested channel; when calibrating a PR through inline comments, place the recommended provisional version in the diff and attach the alternatives to that exact line. + +After the user decides, distill the principle and versions into [the examples](references/examples.md), without PR history or reviewer narration, and apply the learned rule to every analogous passage in scope. diff --git a/.agents/skills/dsh-prose-standard/agents/openai.yaml b/.agents/skills/dsh-prose-standard/agents/openai.yaml new file mode 100644 index 0000000000..52b47167dc --- /dev/null +++ b/.agents/skills/dsh-prose-standard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "DSH Prose Standard" + short_description: "Write concise prose without losing contracts" + default_prompt: "Use $dsh-prose-standard to audit a specified repository scope for required, complete, and concise prose." diff --git a/.agents/skills/dsh-prose-standard/references/examples.md b/.agents/skills/dsh-prose-standard/references/examples.md new file mode 100644 index 0000000000..7edb01af3d --- /dev/null +++ b/.agents/skills/dsh-prose-standard/references/examples.md @@ -0,0 +1,167 @@ +# Distilled prose examples + +Use these examples to identify the governing principle, not as text templates. “Balanced” preserves every load-bearing proposition with the least explanation needed at that location. + +## Preserve every factual clause + +**Original:** “The coordinator carefully serializes writes per session, flushes buffered events before disposal resolves, and reports backend failures to the caller.” + +**Over-trimmed:** “The coordinator serializes persistence.” + +**Balanced:** “The coordinator serializes writes per session, flushes buffered events before disposal resolves, and reports backend failures to the caller.” + +Remove decoration and repetition, not propositions. Actor, per-session scope, disposal ordering, and failure visibility are separate facts. + +## Explicit skill scope is functional + +**Over-trimmed:** “Read the sources and use judgment.” + +**Balanced:** “This skill is guidance, not a complete checklist. Use judgment beyond the named checks; documented requirements still apply.” + +**Over-detailed:** Several paragraphs defending why lists cannot replace independent reasoning. + +Keep the explicit limitation because it changes how an agent applies the workflow. Trim repeated persuasion, not the guardrail. + +## A cookbook keeps action and verification + +**Over-trimmed:** “Add tests for the tool.” + +**Balanced:** “Test registration and disposal at unit level, exercise the tool through the real loader path, and add a snapshot when its rendered output changes. Verify the assertion observes the external result rather than the model's report.” + +**Over-detailed:** A walkthrough of every fixture file and assertion already visible in the example code. + +Keep the test tiers, required action, real entry path, and observable verification. Remove fixture narration. + +## Preserve ownership and timing + +**Over-trimmed:** “Provider work is cancelled during teardown.” + +**Balanced:** “The runtime requests provider cancellation before releasing the child scope; the provider remains responsible for joining its workers before disposal resolves.” + +**Over-detailed:** A chronological account of every promise and callback used to implement teardown. + +The actor, ordering, ownership boundary, and completion guarantee are separate factual clauses. + +## Event JSDoc preserves boundary timing + +**Over-trimmed:** “Composes and caches the session prefix.” + +**Balanced:** “Composes the session prefix once before the first pre-step and request boundary. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.” + +**Over-detailed:** A walkthrough of the loop helpers, cache fields, and promise callbacks that implement the ordering. + +Event order and its current-request consequence are caller-visible behavior, not implementation narration. + +## Orient complicated code without narrating it + +**Over-trimmed:** “Worker realm support.” + +**Balanced:** “Owns the worker realm and its host bridge. Realm initialization is single-shot; disposal terminates the worker and rejects later calls. See the worker-isolation RFC for the protocol rationale.” + +**Over-detailed:** A paragraph-by-paragraph preview of the classes and helper functions below. + +Keep role, boundaries, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. + +## Public JSDoc includes failures + +**Over-trimmed:** “Returns the realm global.” + +**Balanced:** “Returns the initialized realm global. Throws if initialization has not completed or the realm has already been disposed.” + +**Over-detailed:** The internal state-machine branches and exact helper calls that lead to each throw. + +Throws and state preconditions are caller-visible contract facts. + +## Keep a concise implementation mapping + +**Over-trimmed:** “Search provider backed by an external API.” + +**Balanced:** “Maps each provider result to the shared search-result shape, preserving the title, URL, and text while omitting provider-only ranking metadata.” + +**Over-detailed:** A field-by-field restatement of the mapping code, including fields with identical names and obvious assignments. + +Keep mapping details that explain an abstraction boundary or intentional information loss. + +## Link rationale while keeping the local contract + +**Over-trimmed:** “Disposal is documented in the lifecycle RFC.” + +**Balanced:** “Disposal aborts the run and waits for provider quiescence. See the lifecycle RFC for ownership and race handling.” + +**Over-detailed:** Repeating the RFC's promise choreography and rejected ownership models beside every disposer. + +Keep the behavior and completion guarantee where callers need them. Link aggressively for the algorithm and rationale; a link cannot replace the local contract. + +## Implemented RFCs retain verification contracts + +**Over-trimmed:** Deleting the entire Testing section because the RFC has already shipped. + +**Balanced:** “Unit tests cover cancellation before and after publication, disposal quiescence, and provider reload. A built-entry smoke covers the real loader path; snapshot coverage is deferred because the transport is process-specific.” + +**Over-detailed:** A file-by-file walkthrough of fixtures and assertions with no additional behavioral distinction. + +Remove migration tasks and test narration. Keep the tiers, behaviors they pin, real entry path, and named coverage gaps. + +## A security boundary may need one concrete example + +**Over-trimmed:** “Mounted plugins share the host's authority.” + +**Balanced:** “Mounted plugins share the host's authority; for example, access to `ctx.bash` permits commands with the host executor's privileges.” + +**Over-detailed:** A list of every service a plugin could misuse and every hypothetical exploit. + +Keep one example when it makes an otherwise abstract boundary operationally clear. + +## Delete reasoning transcripts entirely + +**Over-detailed:** “First the loop checks whether the value is absent. If it is absent, the next branch returns early. Otherwise it continues, which is why the final assertion is safe.” + +**Balanced:** No comment when the code already expresses those branches. If the early return protects a non-obvious invariant, state only that invariant. + +Do not compress a reasoning transcript into shorter narration; remove it. + +## Configuration comments explain what the tree cannot + +**Over-detailed:** “This entry loads the local filesystem provider, followed by the policy plugin, followed by the read, write, and edit tools,” when the adjacent entries already show that order. + +**Balanced:** “Load policy before the model-facing tools so their write and edit calls pass through the read-before-mutation gate.” + +Keep the consequence of order, a surprising scope rule, or a security boundary. Let the configuration show its own inventory. + +## Do not trim for word count alone + +**Current:** “The adapter converts provider errors into the shared error type so callers can handle authentication, rate-limit, and transient failures uniformly.” + +**Shorter but worse:** “The adapter normalizes provider errors.” + +**Balanced decision:** Keep the current sentence unless a link or surrounding contract already carries the failure categories. The shorter version loses the consequence and distinctions without improving structure. + +## Model-visible text follows ownership + +**Over-trimmed:** “The tool returns errors when a call fails.” + +**Over-detailed:** Copying another package's schema and renderer strings into this backend's README. + +**Balanced:** Quote stable prompt, result, and error text owned by this package. Link the generated tool catalog for schemas and the consumer README for text another package owns; state only this package's conditions or deltas locally. + +Wording that reaches a model is behavior, but duplication still drifts. Exactness belongs at the owner. + +## Generated summaries must stand alone + +**Over-trimmed:** “Approval request and policy service.” The owner explains policy order and audit logging later, but the catalog exports only its first sentence. + +**Over-detailed:** Moving the service's full lifecycle and prompt-notice behavior into the extracted sentence. + +**Balanced:** “Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.” Keep non-catalog detail in later sentences. + +Know what the generator extracts. That fragment must preserve the contract needed on its generated surface. + +## Limitations are contracts, not debt inventories + +**Over-trimmed:** Omitting a process-lifetime cache that makes configuration changes require plugin reload. + +**Over-detailed:** Listing private helper cleanup and unused test-only accessors with no caller or maintainer consequence. + +**Balanced:** “Provider selection is cached for the plugin lifetime; installing or repairing a provider requires reload.” Keep ordinary cleanup in its TODO or RFC. + +Retain gaps and non-obvious constraints that affect use or safe maintenance. A package README is not a backlog dump. diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 1a5cc03549..553cf6f9d1 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -14,7 +14,8 @@ These are authoritative; read them at the source so this skill never drifts out - **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest. - **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). - **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. -- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's prompt template (placeholder contract + the verbatim prompt body). Agents following THIS skill do not render that template; it exists so the pipeline and this skill share one set of rules — a rule change lands in both or it is a bug. +- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's machine-consumed template. Agents using this skill do not render it; keep rules shared with this skill synchronized. +- **[dsh-prose-standard](../dsh-prose-standard/SKILL.md)** — required prose coverage and editorial judgment. Apply it to both sides without adding or dropping source propositions. ## Find the work @@ -38,9 +39,9 @@ Do not process every file the same way: ## Translate -- **Pass 1 — write, don't transpose.** You are a native technical author of the target language. Read a semantic unit of the source (a paragraph or a tight group), close it, and state its content the way [docs/i18n/style-samples.md](../../../docs/i18n/style-samples.md) does — match the nearest genre sample's register. Shape is the gate's job, not yours: never trade natural phrasing for sentence-by-sentence correspondence. +- **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence. - **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it. -- Write ONLY the final text to the file, never drafts or notes. +- Write only the final text to the file, never drafts or notes. - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, in both directions, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. @@ -56,4 +57,4 @@ Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates ( ## How to respond to translation review -Same discipline as any review in this repo (see [dsh-code-review](../dsh-code-review/SKILL.md) § How to respond): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. +Follow the [code-review reporting guidance](../dsh-code-review/SKILL.md#reporting-findings): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5bf4f7f239..198e7b4e09 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -1,27 +1,12 @@ name: Build single-exe -# Single-file executable (single-exe) builds of the DeepSeek Harness SDK -# runtime. The build pipeline and target platforms are specified in -# docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md: -# each target is built natively on a runner of its own platform (no -# cross-compilation) by scripts/build-exe-for-python-sdk.ts, which deploys -# the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into -# dist-exe/. -# -# The run retains exactly the four wheels that make up one Python release: -# one platform-independent SDK wheel plus one native runtime wheel for each -# supported platform. The matrix still exercises the bare executable and -# source tree, but they are intermediate test inputs rather than artifacts. -# -# Two explicit triggers, deliberately no per-commit CI: the exe is a -# release-style deliverable, and the build (full pnpm build + pnpm deploy + -# pkg across a 3-platform matrix, ~100MB per artifact) is far too expensive -# to run on every push. Either dispatch it from the Actions tab, or put the -# `build-exe` label on a pull request to build that PR's merge result -# (remove and re-apply the label to rerun); any other label leaves the jobs -# skipped. There is no `ref` input on purpose: actions/checkout already -# checks out the ref the run was triggered on — the dispatched branch/tag, -# or the PR merge ref. +# Native builds for the release targets; see +# docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. +# A full target run retains one SDK wheel and three runtime wheels; subset +# dispatch retains the SDK wheel and selected runtime wheels. Bare executables +# and source closures are test inputs. Run manually or label a PR +# `build-exe` (remove and reapply to rerun). Checkout uses the triggering ref, +# so dispatch needs no separate ref input. on: workflow_dispatch: inputs: @@ -36,24 +21,16 @@ on: pull_request: types: [labeled] -# Runs on the same ref supersede each other (per branch/tag for dispatch, -# per PR merge ref for label runs). concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -# Least privilege: the jobs only read the repo; artifact upload needs no -# extra scope. permissions: contents: read jobs: - # Turn the `targets` input into the build matrix. The `matrix` context is - # not available in a job-level `if:` (jobs..if only sees - # github/needs/vars/inputs), so target selection happens here instead of - # skipping matrix legs; an unknown target name fails the whole run loudly - # instead of being silently ignored. The label gate lives here too: `build` - # needs this job, so skipping it skips the whole run. + # Job-level conditions cannot inspect `matrix`, so validate target names and + # construct the matrix before the dependent jobs. plan: name: plan targets if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe' @@ -79,8 +56,7 @@ jobs: - name: Compute matrix from targets input id: plan env: - # Empty on label runs and on dispatch with the input left blank — - # both mean "all three targets". + # Label runs and blank dispatch inputs build all targets. TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }} run: | set -euo pipefail @@ -89,10 +65,8 @@ jobs: for raw in "${targets[@]}"; do t="$(echo "$raw" | xargs)" # trim surrounding whitespace [ -z "$t" ] && continue - # Native builds only — each target maps to a runner of its own - # platform: linux-arm64 uses GitHub's hosted arm64 label - # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64 - # uses macos-latest (Apple Silicon since macos-14). + # Native-only: hosted arm64 Linux uses ubuntu-24.04-arm, while + # macos-latest is Apple Silicon. case "$t" in node24-linux-x64) runner=ubuntu-latest ;; node24-linux-arm64) runner=ubuntu-24.04-arm ;; @@ -168,8 +142,7 @@ jobs: id: pnpm-store run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - # Unlike ci.yml (x64-only), this matrix spans two Linux architectures - # that share runner.os, so runner.arch is part of the key. + # Linux architectures share runner.os, so the cache key includes arch. - uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} @@ -177,12 +150,8 @@ jobs: restore-keys: | ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- - # The first run per target has pkg-fetch download yao-pkg's patched - # Node binary into ~/.pkg-cache; cache it so later runs skip the - # download. The target string pins Node major + platform + arch; - # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the - # pinned patched-binary version) is bumped, with restore-keys still - # seeding from the previous cache. + # Cache pkg's target Node binary; lockfile changes roll the + # exact key while the restore prefix can seed its replacement. - uses: actions/cache@v4 with: path: ~/.pkg-cache @@ -193,8 +162,6 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The script runs the whole pipeline itself (pnpm run build → pnpm - # deploy --prod → pkg) and writes its output to dist-exe/ by default. - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9111f50153..a2015696f4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -82,13 +82,9 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The with-key escalation e2e (examples/acp-agent/tests/ - # escalation.e2e.ts) self-skips without a usable runner — without this - # step it would never actually execute anywhere (CI had no bwrap, dev - # macs run Seatbelt instead), which is exactly how a broken harness - # composition once survived unseen. Same recipe as ci.yml's bwrap - # steps; the userns knob is best-effort (absent on pre-24.04 kernels, - # the probe decides). + # The with-key escalation e2e self-skips without a usable runner. Install + # bwrap so trusted CI exercises it; the userns knob is best-effort and the + # test's functional probe decides. - name: Install bubblewrap (unrestrict userns) run: | sudo apt-get update -q diff --git a/AGENTS.md b/AGENTS.md index 4dfbedb97b..50e00dcc20 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 @@ -25,9 +25,10 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP bridge, JSON-RPC SDK server, app-boot glue, stdio/ACP/SDK app bins, user-approval and user-interaction seams, ask-user tool + ui/ ACP/stdio/JSON-RPC front doors; boot, approval, and interaction plugins support/ dev/test infrastructure packages util/ zero-dependency utilities +python/ Python SDK and bundled runtime (see python/README.md) examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators @@ -58,7 +59,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 @@ -79,51 +80,53 @@ 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 and demos read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the environment or a gitignored root `.env` loaded by `process.loadEnvFile()`. 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 defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. 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. +- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. +- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. +- **Validate RFC premises against current code**; friction may expose overreach, so 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, and schedule any missing harness support before implementation. +- **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 preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions 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 each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. ## Vendoring policy diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 94438f06b1..0c305e87c8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md — The documentation standard -This is the repo's Markdown placement, writing, and budget contract. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) to apply it; the [doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) records the rationale. +This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale. ## The tier taxonomy: one home per fact -Each fact has one owning tier; other tiers link to it. Restated rules drift, while `verify-md-links` keeps links resolving. +Each fact has one home: the tier whose job it is. Elsewhere, link to that home; `verify-md-links` keeps links resolving while duplicated prose drifts. | Tier | Job | Does NOT belong there | |---|---|---| @@ -12,29 +12,30 @@ Each fact has one owning tier; other tiers link to it. Restated rules drift, whi | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | | [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ RFCs), implementation-status annotations | | [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) | -| [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped | +| [rfc/](rfc/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` RFCs describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | -| Skills (`.agents/skills/`) | Workflows: how to carry out a recurring task against the contracts | The contracts themselves (→ docs) | +| Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; type shapes → core data; package promises → READMEs; standing orders → root `AGENTS.md` with a link to their rationale. +Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. ## 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. -- **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. +- **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. +- **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). +- **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; 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)). - **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 state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. - 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: @@ -42,7 +43,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings keep working headroom: at least 5% above the current size, ratcheted down after trims. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling; review and the slop checklist govern them. +Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. ## The slop checklist @@ -53,12 +54,15 @@ 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. +- Hand-maintained inventories of tests, packages, or implementation status when the tree or a generator is authoritative. +- 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. +- The same rationale repeated beside sibling methods. State it once at the owning seam or shared helper. - 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/architecture.md b/docs/architecture.md index 8dc7f6f4b1..d9c3d86a1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: the loop sends `renderPrompt(await assemble(assembleContextFor(agent)))`; the helper couples the explicit agent and scope. Plugins contribute ordered sections, tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's global default persona (order 0, shadowable by a same-named agent-scoped section) — while the loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. @@ -110,7 +110,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Scope -Every live agent owns `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), keyed by the agent). Its registrations are visible only to that agent, shadow same-named globals, and unwind with it. Its listeners hear only that agent's dispatches; an opaque carrier routes while the real subject stays explicit. `CreateAgentOptions.setup(agentCtx)` composes this world before publication and does not drive. Dev invariants and `verify-scoped-dispatch` keep carrier/subject identity aligned with event declarations. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent `persona`, `toolFilter`, and `maxDepth` are the separate [composition-controls feature](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State @@ -138,7 +138,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca ### Bundles And Apps -`dsh-agent-core` is the default composition bundle: one plugin loading the shared spine ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-agent` for terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-core` is the default composition bundle: one plugin loading the shared spine ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-agent` for terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4d04fe27f..f9cd19af22 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -18,20 +18,14 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } ``` 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:203`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -63,22 +57,19 @@ 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:31`](../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, 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. + * The schema intersects the owners' schemas, which supply defaults for every + * optional input and keep validation from drifting. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -106,7 +97,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:46`](../packages/core/agent-core/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -129,7 +120,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -149,7 +140,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:21`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -176,7 +167,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:26`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -212,7 +203,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:30`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` @@ -265,7 +256,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:49`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -276,7 +267,7 @@ Requires: `bash` export interface Config { /** * Path to a `hooks.json` or a settings file whose `hooks` key holds the config. - * PROCESS-LEVEL: read once at load, a relative path resolves against the process + * Process-level: read once at load, a relative path resolves against the process * launch cwd, so one config applies to the whole process. * TODO(per-session-hook-config): per-session discovery of a project-local * `hooks.json` from each `session/new.cwd` is not yet implemented. @@ -301,7 +292,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -311,7 +302,7 @@ Requires: `bash` /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** - * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative + * Path to a Codex `hooks.json`. Process-level: read once at load, a relative * path resolves against the process launch cwd. * TODO(per-session-hook-config): per-session project-local discovery from each * `session/new.cwd` is not yet implemented. @@ -326,43 +317,27 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:46`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` Requires: `agents` ```ts config-catalog -/** - * Plugin config. Every field is a runtime-only test seam — none is part of the - * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` - * (production always serves the process stdio and exits via `process.exit`). - */ +/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ export interface JsonRpcConfig { - /** - * Transport input override. Production omits this (the plugin reads - * `process.stdin`); tests inject an in-memory `Readable` to drive the server - * without a subprocess. - */ + /** Transport input override; production uses `process.stdin`. */ input?: Readable - /** - * Transport output override. Production omits this (the plugin writes - * `process.stdout` — the protocol channel); tests inject an in-memory - * `Writable` to capture frames. - */ + /** Transport output override; production uses `process.stdout`. */ output?: Writable - /** - * Process-exit override for the `shutdown` request path. Production omits - * this (`process.exit`); tests inject a recorder so a driven shutdown does - * not kill the test process. - */ + /** Process-exit override; production uses `process.exit`. */ exit?: (code: number) => void } ``` Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) -Source: [`packages/ui/jsonrpc/src/index.ts:55`](../packages/ui/jsonrpc/src/index.ts) +Source: [`packages/ui/jsonrpc/src/index.ts:26`](../packages/ui/jsonrpc/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -389,7 +364,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:30`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -442,7 +417,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:306`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -459,10 +434,7 @@ export interface Config { presets?: Record } -/** - * One preset's knob bundle — the sandbox mode and approval policy a session - * runs under while the preset is active — plus its presentation. - */ +/** One preset's sandbox/approval bundle and optional client presentation. */ export interface PresetSpec { /** The `bash/sandbox-mode` value the preset writes through. */ sandbox: SandboxMode @@ -477,7 +449,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:97`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:80`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -509,7 +481,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:27`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -517,20 +489,10 @@ 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 runner argv; bwrap-shaped profile arguments are appended. A + * non-empty override asserts full enforcement and skips built-in selection and + * probing; a broken runner then fails at execution and must be identifiable by + * {@link runnerFailureSignatures}. */ runnerCommand?: string[] /** @@ -542,21 +504,12 @@ export interface Config { * own failure dialect. */ runnerFailureSignatures?: string[] - /** - * Per-probe timeout in milliseconds for the chain's functional probes - * (default: 5000; must be a positive finite number — Node treats a 0 - * `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A - * probe that exceeds it reads as an unusable rung, so a - * host slow enough to trip the default — cold NFS mounts, heavily loaded - * CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no - * config escape. Bounds ONE probe, and the chain walk runs each at most once - * per provider lifetime. - */ + /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ probeTimeoutMs?: number } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:36`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:20`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -574,7 +527,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:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -609,7 +562,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:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` @@ -694,7 +647,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:36`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -737,20 +690,11 @@ export interface Config { disposeGraceMs?: number } -/** - * How the client answers a child's `session/request_permission`. The first cut - * does not surface permission prompts to a human, so every request is - * auto-answered by this fixed policy: - * - * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child - * that asks before a side effect does not get to take it. - * - `allow` — approve every prompt by selecting its first `allow_*` option (or, - * if none is offered, `cancelled`). Use when the child is trusted to act. - */ +/** Fixed response to child permission requests: reject by default, or select the first allow option. */ 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:18`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-fork` @@ -764,7 +708,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:25`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-mock` @@ -799,7 +743,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:97`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -813,7 +757,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` @@ -821,48 +765,20 @@ 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. + * Deployment-wide order-0 persona template. A scoped section named + * `deployment:persona` shadows it; `{{variable}}` references are strict. */ 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. + * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. + * Shape errors fail at load and unknown names fail at assembly; known names + * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] } ``` -Source: [`packages/core/system-prompt/src/index.ts:227`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:147`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -880,7 +796,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:25`](../packages/cordis/tool-cordis/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` @@ -900,7 +816,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:31`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` @@ -975,7 +891,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` @@ -997,7 +913,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:29`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -1013,7 +929,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:26`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -1023,18 +939,10 @@ 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. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. + * Code modes require a TypeScript runtime and fail prompt assembly when it is + * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. */ mode?: ToolPresentationMode } @@ -1043,7 +951,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:400`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1074,7 +982,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:270`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:214`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` @@ -1093,7 +1001,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:59`](../packages/web/web/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -1143,7 +1051,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:40`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -1215,7 +1123,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:36`](../packages/workflow/workflow-workerthread/src/index.ts) ## Loadable plugins with no config diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 73274ce1eb..db0684b762 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -42,7 +42,7 @@ For a swappable capability, split interface / implementation / consumer into sep ## 4. Write the package README -Keep package-specific service API, config, events, extension points, and design notes first. Document only behavior, limitations, and deferred work owned by this package: a feature already implemented elsewhere is not this package's limitation. An indirect Model Experience sentence may name the consumer that surfaces this package's contribution, but it does not restate that consumer's implementation. End a package README with this canonical sequence: +Keep package-specific service API, config, events, extension points, and design notes first. The limitations section records durable consumer gaps and non-obvious maintainer constraints owned by this package; ordinary cleanup stays in its source TODO or RFC. An indirect Model Experience sentence may name the consumer that surfaces this package's contribution, but it does not restate that consumer's implementation. End a package README with this canonical sequence: ````markdown ## Model Experience @@ -61,12 +61,12 @@ Stable system-prompt prose of any length, or another long non-generated literal, ## Known Limitations and Deferred Work -- **Consumer-visible gap** — exact boundary or deliberately deferred work. +- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -Fill Model Experience from the implementation. Direct, multi-surface, conditional, capped, or lifetime effects use one H3 block per context surface; each block has the exact bold-led `What the model sees` and `Token effect` fields shown above. A structured section grounds at least one surface in concrete model-visible text through inline code, a nested `markdown` block, or an anchored tool-catalog link. Put every stable system-prompt paragraph, including a one-liner, in a titled H4 plus `markdown` fence immediately after those fields inside the owning H3 whose title contains `system prompt`; never leave prompt prose in inline code. Quote other short stable model-visible source literals inline, using named placeholders such as `` only for interpolated values, and attach other long non-generated literals to their owning H3 in the same H4-plus-fence form. Describe an attached literal as the text "below" instead of linking between Model Experience subsections; the physical nesting already records ownership. A tool-schema surface uses `schema` in its H3 and links the relevant anchored package section in the generated [tool schema catalog](../tool-catalog.md) instead of copying its default descriptions or JSON Schema; describe only configuration or composition deltas the catalog does not contain. A runtime-only definition outside the catalog's stated scope links that scope and explains the exception before reproducing its stable text. Summarize data-dependent payloads or provider-owned text by identifying their exact shape and renderer. Do not infer prompt visibility from tool-schema visibility because independently registered guidance can remain after a scoped tool restriction. +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the two fields shown above. Quote stable text owned by the package: system-prompt prose goes in a titled H4 plus `markdown` fence, other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. -An audited package with no context effect or one simple consumer-owned path belongs in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) and uses one line beginning `None, as ` or `Indirectly, through `. A generic package whose public contract is model-agnostic may instead join the narrow `NO_MODEL_EXPERIENCE_SECTION` allowlist and omit the heading entirely; the allowlist retains the audit reason so absence cannot mean forgotten documentation. Pure transport and keyless test-support packages otherwise use `None, as ` when they create no model-bound content even if consumers use them during composition. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses `Indirectly, through ` even when it caps or filters data before returning it; so does a wiring bundle whose model effects all belong to named children. Do not give these packages a structured block describing another package's work. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks; the verifier gates their H3 headings, field labels, spacing, concrete literal evidence, nested H4-plus-`markdown` blocks, absence of local subsection links, system-prompt literals, and schema-surface-to-catalog links. A package with genuinely no limitations joins the separate allowlist in [`verify-package-readme-limitations.ts`](../../scripts/verify-package-readme-limitations.ts); the two omission allowlists are independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. +A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts); a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. ## 5. Verify diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 2912fde6e4..86803fdebb 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -36,7 +36,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. -- **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means). +- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). @@ -53,7 +53,7 @@ Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for ## Code Mode reaches your tool for free -Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), each visible registered capability is callable from a `run_code` program as `await tools.(args)` — nothing to add. The registry keeps `run_code` itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`); each program call receives its own immutable execution whose `parent` is the enclosing `run_code` token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design `description` and parameter `description`s as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca). +In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The SDK derives parameters from the same JSON Schema, and calls re-enter the normal execution pipeline. Write descriptions as model-facing API docs; non-text result blocks become placeholders in programs. ## How your tool renders in an editor (ACP presentation) @@ -65,7 +65,10 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) -- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create; `write`/`edit` attach the hunks via the `meta` channel and read them back here). A mutation tool returns the `diff` result even when it duplicates the call-time card, because an ACP `tool_call_update.content` REPLACES the call's content — a non-diff result would clobber the pending diff. `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. +- `presentResult(args, { content, isError, meta? })` returns the completed card: + - `generic` supplies an optional title and content. + - `terminal` supplies raw output and optional exit metadata; the bridge renders the capability-specific or fenced fallback view. + - `diff` supplies applied hunks, often carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. Hard rules (they bite if broken): @@ -77,4 +80,4 @@ The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a too ## Tests every tool needs -Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path. +Cover argument rejection, every result shape, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and `tool/result` session events. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e2677e463a..a2aa255ccf 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: correlate and settle each request exactly once from the durable `turn/end` session event even if rendering fails, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. +A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index acbd39b8fa..aa87ea66d7 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. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. +A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,11 +23,11 @@ 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:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs. +An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. ```ts cordis-catalog 'agent/disposed'(this: Scoped, agent: Agent): void @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,13 +47,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:605`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:283`](../../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 serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that 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 +59,11 @@ 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:438`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../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. +Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise @@ -73,11 +71,11 @@ 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:456`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit -A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options. +Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -85,11 +83,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv 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:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../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. +Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,15 +95,11 @@ 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:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../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. - -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`. +Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -113,11 +107,11 @@ 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:537`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../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): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts. +The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts. ```ts cordis-catalog 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void @@ -125,11 +119,11 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../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`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void @@ -137,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,11 +143,11 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../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. +Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise @@ -161,11 +155,11 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis 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:260`](../../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. +Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. ```ts cordis-catalog 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -173,13 +167,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:588`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../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 a readonly same-process value borrowed from the caller. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -187,13 +181,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:70`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:31`](../../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 for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins. ```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 +195,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:59`](../../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 a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -213,11 +207,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:68`](../../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 for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers. ```ts cordis-catalog 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise @@ -225,7 +219,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,27 +239,27 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced detach does not remove the entry immediately: removal and the paired `session/disposed` edge wait until the creation dispatch unwinds. 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. +Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit -A previously announced session left the store. Emitted exactly once on normal detach or publication rollback, and never for a prepared/entered session whose `session/created` announcement did not begin. Listener failures (including returned-promise rejections) are logged and contained per listener so teardown always reaches quiescence. Scope-filtered dispatch uses the same owner carrier captured at entry; agent-scoped listeners hear only their own session's teardown. +Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:55`](../../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. The log push is the commit point; synchronous throws and returned-promise rejections from observers are logged and contained per listener, so they cannot make a committed append appear to fail or starve later listeners. The exact callback list and Cordis internal-dispatch checks resolve before the push; callbacks themselves run only after it. 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. +Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog 'session/event'(this: Scoped, session: Session, event: SessionEvent): void @@ -273,17 +267,17 @@ 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:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:66`](../../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 parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) ## `skill/*` @@ -353,27 +347,23 @@ Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/s ### `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. - -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). - -The returned assembly is authoritative. This is an expert composition seam: a listener that removes or replaces another plugin's protocol contribution owns preserving that protocol's invariants. +Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. ```ts cordis-catalog 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:49`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit -A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope. ```ts cordis-catalog 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:59`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` @@ -385,11 +375,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:116`](../../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 tool and scope the pipeline accepted. (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 for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -397,11 +387,11 @@ 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:128`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:89`](../../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). +Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise @@ -409,11 +399,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:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:98`](../../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. 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). +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -421,11 +411,11 @@ 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:101`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit -Synchronous 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. +Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. ```ts cordis-catalog 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined @@ -433,7 +423,7 @@ Synchronous notification of the authoritative FINAL tool outcome, after the comp Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) ## `workflow/*` @@ -445,7 +435,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:96`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit @@ -455,7 +445,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:85`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -465,7 +455,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:106`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit @@ -475,7 +465,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:75`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) ### `workflow/phase` — emit @@ -485,7 +475,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:68`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) ### `workflow/start` — emit @@ -495,7 +485,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:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:45`](../../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 4495af1b28..2b423bfb1f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:335`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -38,13 +38,11 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:133`](../../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. +Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices. ```ts cordis-catalog async request(req: ApprovalRequest): Promise @@ -52,18 +50,11 @@ 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:294`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:229`](../../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()`). +Registers one `ctx.bash` implementation. Runtime command failures resolve as BashRunResult; only infrastructure failures reject. Background starts return immediately without a timeout, report completion exactly once while live, and remain cancellable by signal or kill. Output reads are incremental and flag lost buffered data; disposal kills and awaits all tasks. ```ts cordis-catalog abstract resolve(request: BashExecRequest): BashExecSpec @@ -79,18 +70,11 @@ 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:38`](../../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()`). +Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog abstract run(request: CodeRunRequest): Promise @@ -98,18 +82,11 @@ 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:31`](../../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. +Abstract compaction service. Implementations own token estimation, retention, and summarization, but a successful run must replace the selected surface span with one summary node and prevent concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise @@ -118,20 +95,11 @@ 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:36`](../../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`). +Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```ts cordis-catalog abstract resolve(path: string, opts?: { cwd?: string }): Promise @@ -145,7 +113,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` @@ -159,11 +127,11 @@ 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:75`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` -The permission service (`ctx.permission`). Owns the deployment's preset table and THE write path for preset switches; presentation layers (the ACP bridge's single `Permissions` select) advertise names and call set. Composing it REQUIRES both mechanism knobs — a confining `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no table entry is not an error but the derived CUSTOM_PRESET state: shown as the current value, never a switch target. +Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error. ```ts cordis-catalog current(events: readonly SessionEvent[]): string @@ -174,17 +142,11 @@ set(session: Session, name: string): void Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:115`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/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`. +Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. ```ts cordis-catalog abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv @@ -192,18 +154,11 @@ 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:111`](../../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 events.** `SessionEventMap` is merge-extensible, so append materializes each complete batch through the shared lossless-JSON boundary before buffering it. The public `session.events` view is immutable, but persistence still snapshots direct/replay callers at this independent trust boundary. -- **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). +Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog abstract create(meta: SessionHeader): Promise @@ -214,7 +169,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:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` @@ -245,7 +200,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:560`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -275,7 +230,7 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). +Registry service for the prompt inputs assembled before each model step. ```ts cordis-catalog section(section: PromptSection): () => void @@ -284,13 +239,11 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:342`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:213`](../../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 private visibility resolver feeds the registry's prompt contribution, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so those registry-owned presentation and dispatch paths agree. An expert `system-prompt/assemble` listener may deliberately replace the final wire composition and owns any resulting divergence. +Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog register(definition: ToolDefinition): () => void @@ -303,7 +256,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:492`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -336,24 +289,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:78`](../../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 (borrowed immutable data, 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. +Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles. ```ts cordis-catalog abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:211`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 94c5cb9a7f..d0161372d7 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -108,7 +108,7 @@ interface BashExecSpec { The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. -`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 — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +Trusted in-process plugins use `stdin` and `env` for hook payloads and hook-specific variables. The model-facing bash tool constructs requests from its named schema fields and exposes neither input because shell syntax already provides equivalent power; tests guard against a future `...args` spread. This is request-shape discipline, not a security boundary: `dsh-bash-local` scrubs ambient credentials regardless of these fields, then overlays explicit values already held by the caller. See [the bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 2f402be57d..82bf512eeb 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy. +Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5536526aec..9a9a1706fc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -157,17 +157,8 @@ 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.) + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> } @@ -202,7 +193,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) and the [reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). + +`agent/request` receives a frozen call-config seed and may return a replacement. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. @@ -354,7 +347,9 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. + +The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. ## Interception decisions @@ -397,7 +392,7 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. +`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides. ## `ToolDefinition` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index bbc55be966..6c6dd3a130 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,6 +1,6 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. +The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 56ac91a6da..93189ba2cb 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -6,7 +6,9 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind ## Provider registry -`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Provider objects, lookup options, and candidates are readonly same-process contracts, so the registry borrows them instead of manufacturing defensive snapshots. The registry still validates semantic fields, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. +`ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. + +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. ```ts type-equiv interface SkillProvider { diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5454f326b3..28093165bd 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -21,7 +21,7 @@ interface SubagentCapabilities { ## The start request -What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The four optional fields (`outputSchema`, `maxDepth`, `toolFilter`, `persona`) each gate on the matching `SubagentCapabilities` flag — in-process backends realize `toolFilter` as a scoped `tools.restrict()` and `persona` as a scoped shadowing `deployment:persona` section, both composed in the child's creation window. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)). +The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv interface SubagentStartRequest { @@ -64,7 +64,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -The handle the consumer holds after a provider has established a ready child. The consumer awaits `result` and MUST `dispose` on every path to cancel remaining work and reach child quiescence. `result` 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` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. +`SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. ```ts type-equiv interface SubagentRun { @@ -78,7 +78,7 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance. +Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv interface SubagentProvider { @@ -89,11 +89,11 @@ interface SubagentProvider { } ``` -`SubagentProvider.start()` and `ctx.subagents.start()` are the publication boundary: their promises fulfill only with a ready run. The service attaches result observation, emits `subagent/start`, and returns the same holder-owned run; a rejected start has already cleaned provider-owned partial resources and emits neither lifecycle event. For an in-process provider, a start listener can 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 and reports `error` on infrastructure rejection. Both lifecycle events are observe-only emits with per-listener exception containment. +`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed -The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as an ordinary `Agent` in the same application. The provider creates it directly through `parent.ctx`, passes the required signal into the core creation transaction, and delegates quiescent disposal to the returned `AgentHandle`. Provider removal prevents new starts but does not revoke an accepted run. The child receives a flat new scope rather than inheriting the parent's registrations. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: - **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 36c59aea17..132d22d521 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -127,7 +127,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is a compile-time opaque fresh `Symbol` at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` materializes `arguments` as detached lossless JSON, assigns the token, and deep-freezes the accepted arguments. A non-JSON value is normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are readonly throughout the waterfalls, while an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers. +`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. @@ -185,7 +185,9 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. + +Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. ## The structured-output schema subset diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index f6bd6406ab..9424b9ed70 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with platform-native `fetch` at the repo's Node floor, mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. +`WebService` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, caps redirects, bytes, characters, and time, revalidates every same-origin redirect hop, and decodes the body; the tool owns presentation. Private-network blocking is deferred, so do not enable `web_fetch` where it can reach sensitive internal targets. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 1959a44b4d..cb4807c048 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: ea5f2e5d08acbaf1dfce4661530218dbf1a051b9 -development.zh.md: 9c0cc23373b0f4feba8538fcbd25b38a483e0ba0 +development.md: 376df72c14b59b8ac8e42b31f19442040d6a47a7 +development.zh.md: 676df50a8a1ab2eff3ec829b5d55904626fa3f5e diff --git a/docs/development.md b/docs/development.md index ea5f2e5d08..376df72c14 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,9 +67,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou ## CI gates -The keyless GitHub workflow has eight jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and three compatibility jobs run `pnpm run check:node-compat` on Node 22.19, 24, and 26. The compatibility command runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke on every runtime, so the matrix proves that the source graph typechecks and that a real unbuilt Worker loader path executes; the other lane schedulers fan out independent gates from `package.json`: constraints, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test. - -`pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=14`. +The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index 9c0cc23373..676df50a8a 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -67,9 +67,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v ## CI 门禁 -无密钥 GitHub 工作流共有八个任务:五条 Node 24 车道分别运行静态门禁、lint、覆盖率、快照回放与产物门禁;三个兼容性任务在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。兼容性命令会在每个运行时上执行 TypeScript 类型检查,并运行无密钥的 workflow-workerthread 源码启动冒烟测试,因此该矩阵既证明源码图能通过类型检查,也实际执行了一条未构建的 Worker loader 路径。其余车道调度器从 `package.json` 展开相互独立的门禁并发运行:constraints、lint、覆盖率、快照回放、`doc-sync` 各成员、module-graph 新鲜度、`knip`,以及 echo-agent 冒烟测试。 - -`pnpm run build` 供给产物车道;`publint`、`verify-node-next-types` 与 built-bin 冒烟测试等待构建产出。独立的真实 API 工作流使用密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。 +keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 ## 日常命令 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b0a82673e..d1a097fe24 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,47 +7,47 @@ 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:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../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:456`](../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:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../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:381`](../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:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`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:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../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:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../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:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../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:212`](../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:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../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:180`](../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:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`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:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../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:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../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:59`](../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:68`](../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:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:66`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../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:72`](../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:82`](../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:49`](../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:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../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:148`](../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:101`](../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` | `emit` | [`packages/core/tools/src/index.ts:163`](../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:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:75`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../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:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../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:98`](../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:80`](../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` | `emit` | [`packages/core/tools/src/index.ts:106`](../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:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../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/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index d9312030c9..43c64ddd90 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: d600773af2be156f52746c5bbd1853ff2ab46c14 -README.zh.md: eac836e7c310945ec345db9974be9f1deaae4c60 +README.md: 3a5965ab60a7bdb6345b6abe6815f7090fa98fe1 +README.zh.md: 8e62b53ffefa25ad0bbf713889a2ec2ecdb7027a diff --git a/docs/i18n/README.md b/docs/i18n/README.md index d600773af2..3a5965ab60 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -40,12 +40,12 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): -- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, and `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. - `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. -**Rollout**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. Files dated before the cutoff are the grandfathered backlog by definition — including files created on the cutoff's eve — and a document's filename date is its first-proposed date per the RFC convention, so backdating past the cutoff is a review-visible violation, not a loophole. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Pairs land in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any pair that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. +**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An RFC filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support. ## Division of labor diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index eac836e7c3..8e62b53ffe 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -39,12 +39,12 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -**推进**:新增文档不再等待批量翻译。以日期命名的文档(`yyyy-mm-dd-*.md`,即 RFC),只要标注日期等于或晚于 manifest 里的 `requiredSince` 分界日期,合入时就必须配齐双语配对——所有新文档从创建起就要求双语齐备。日期早于分界的文件按定义属于豁免的存量(包括分界前夜创建的文件);文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过分界属于评审可见的违规,构不成漏洞。对于存量文档,manifest 中的 `required` 列表只是当下的执行红线,并非最终目标;目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、实操手册(cookbook)、RFC、事故复盘(postmortem)等);每个批次合入后把对应文件加进 `required`,门禁只向前收紧、不倒退放宽。尚未进入 `required` 的文档是待翻清单(backlog),可通过 `--list` 查看;但任何已存在的配对,无论是否在清单内,都按完整契约检查。为一篇文档建立配对等同于一份长期承诺:此后修改任一侧,都必须同步更新另一侧。因此执行红线的推进节奏要匹配翻译评审的实际投入,切勿超前铺开。 +**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 RFC),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合入时就必须配齐双语文件。更早日期的文件属于待翻清单(backlog),包括分界前夜创建的文件。RFC 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。 ## 分工 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 9978e2d29b..a300ef64f4 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -1,6 +1,6 @@ # Translation prompt (pipeline asset) -本文件是自动翻译流水线使用的 prompt 模板,正文(自 `# Translation Prompt` 起)逐字进入模型请求,不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与其内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写。模板与仓库规则的关系:[terminology.md](terminology.md) 在渲染时整表填入 `{{terminology}}`;文体金标见 [style-samples.md](style-samples.md),模板内嵌的 Examples 是其中问题类别的最小抽样,两者冲突时以 style-samples 为准。修改本文件即修改线上翻译行为,按正常 PR 评审。 +本文件是自动翻译流水线的 prompt 模板;自 `# Translation Prompt` 起的正文逐字进入模型请求,因此不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时,[terminology.md](terminology.md) 整表填入 `{{terminology}}`。[style-samples.md](style-samples.md) 定义文体,模板内嵌的 Examples 仅抽样问题类型;两者冲突时以文体样例为准。修改本文件即修改翻译行为,需按正常 PR 评审。 ## 占位符契约 @@ -14,7 +14,7 @@ | `{{source_filename}}` | 源文档的 basename(如 `foo.md`) | 由流水线从待译文件路径取得 | | `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 由 `{{source_filename}}` 派生 | -历史模板的 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 占位符与 `%%` 分段协议已废弃:本模板按整文档翻译(非分段),输出协议为下方三段 XML。 +流水线仅支持上表占位符,并按整篇文档翻译。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 或 `%%` 分段协议;输出采用下方三段 XML。 ## Few-shot 金标 @@ -78,7 +78,7 @@ You are a senior technical translator specializing in LLM and agent development - For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. #### When translating into English -(To be added.) +- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text. ## Terminology diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 216a764601..a27b19b9a7 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:84`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:45`](../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:95`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:56`](../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:107`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:68`](../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:322`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,19 +69,19 @@ 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:329`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) ### `bash/*` #### `bash/sandbox-mode` — log-only -The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); execution and ACP config-option reporting fold it without adding prompt text or a context notice. +Durable log-only sandbox-mode override; never a surface event or model message. Execution and ACP option reporting fold the latest event through effectiveSandboxMode without adding a prompt notice. ```ts persistence-catalog '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:20`](../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:38`](../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:15`](../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:22`](../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:320`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `hook/*` @@ -141,35 +141,35 @@ 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 outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime. ```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:31`](../packages/hooks/hook-protocol/src/types.ts) ### `permission/*` #### `permission/preset` — log-only -The session's permission preset was switched — log-only (the `bash/sandbox-mode` precedent): durable and replayable, never in the model transcript. The LAST such event is the session's preset (effectivePermissionPreset); the knob events the switch wrote through follow it in the same turn, and they — not this record of the user's choice — are what execution reads. +Records the selected preset as durable, log-only user intent. The knob events follow in the same turn and control execution; this event stays out of the model transcript and lets effectivePermissionPreset preserve a selection when bundles match. ```ts persistence-catalog 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:42`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src/index.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()`. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -177,29 +177,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:314`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../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 EpochHeader for the next request, appended inside its step before dispatch. It is log-only and anchors subsequent deltas. ```ts persistence-catalog 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../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. +Log-only amendment to the folded EpochHeader. System and tools use their delta codecs; config and prefix replace whole, with an empty prefix encoding removal. Writers verify round-trip equality or log a fallback snapshot. ```ts persistence-catalog 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `steering/*` @@ -213,7 +213,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:347`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) ### `step/*` @@ -225,7 +225,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -235,15 +235,13 @@ 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:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) ### `todo/*` #### `todo/write` — log-only -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. +Whole-list snapshot; the latest write wins on replay. It is log-only UI state and never enters derived model history. ```ts persistence-catalog 'todo/write': { todos: TodoItem[] } @@ -251,7 +249,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) ### `tool/*` @@ -265,11 +263,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:335`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../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 } @@ -277,7 +275,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:25`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface @@ -289,7 +287,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:345`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) ### `turn/*` @@ -303,7 +301,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:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -315,7 +313,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:291`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts) ### `user/*` @@ -329,4 +327,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:303`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 04f3910f1c..e024f4d698 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -4,7 +4,7 @@ Status: resolved (fix in PR #41 `feat/acp-2-bridge`) ## Executive summary -One stray line — `export default apply` at the bottom of the ACP plugin — made the ACP server crash the moment any editor connected, because the cordis Loader unwraps a default export and threw away the plugin's `inject` declaration along with it. A second, independent bug (an optional service read that fails through Cordis's traceable-shadow proxy) crashed `session/load` for a different reason. Both shipped green: 178 unit tests at 100% line coverage never caught either, because every test mounted the plugin by hand instead of through the real loader, and the only test that drove the failing requests was skipped in CI. The fixes are one-line each; the durable lesson is that **line coverage proved the code ran, not that the feature worked the way it ships** — so we added a no-key end-to-end test that boots the real example through the real loader, plus packages/AGENTS.md rules on plugin export shape and optional-service access. +Two integration mistakes broke ACP despite full unit coverage: a default export caused the Loader to discard `inject`, and a traced optional-service lookup failed across a shadow boundary. Hand-mounted tests bypassed both paths. The fixes added keyless real-Loader coverage and package rules for plugin exports and optional-service access. ## Summary @@ -82,7 +82,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob `ctx.reflect.get(name, false)` is a direct lookup in the global service store keyed by the isolate symbol — it ignores fiber topology entirely and finds the service. So from a top-level test the read works; from inside a real plugin fiber, reached via a shadow, it throws. The bridge is exactly the latter. -**Fix:** read the optional service through the same global store the bypass uses, but via the public `ctx.get(name)` — `this.ctx.get('sessionPersistence')` instead of `this.ctx.sessionPersistence`. `ctx.get(name)` is a direct lookup in the global service store keyed by the isolate symbol; it ignores fiber topology, so it resolves the backend regardless of which fiber or shadow the call arrives through. It is strict by default (an inactive/absent backend reads as `undefined`, which the existing guard rejects) — preferable to the `, false` overload, which would additionally skip the active-state check and could hand back a backend mid-teardown. The other reads in the resume path (`this.ctx.sessions`, `this.ctx.agents`) are fine — those *are* in `AgentLoop`'s `static inject`, so they sit in its fiber store and the ancestor walk finds them immediately. +**Fix:** read the optional service with `ctx.get('sessionPersistence')`, which uses the global isolate-keyed store while preserving active-state checks. Direct property reads remain appropriate for services in the plugin's declared injection set. ## Why every test missed it (the real failure) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index fcb077e0a5..a6fe4dbfa2 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo ## Classification -Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and [INDEX.md](INDEX.md) is **generated** from the tree in full (`pnpm run gen-rfc-index` rewrites it from each RFC's path, H1 title, and filename date; the gate fails when it is stale, and rejects an index-shaped row in this file). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the index is generated while this prose stays curated. +Each RFC belongs to one path-encoded class from the closed set in `scripts/rfc-index.ts`; the classification gate rejects other folders. [INDEX.md](INDEX.md) is generated from paths, titles, and filename dates, and its freshness is gated. Adding a class requires updating the canonical set and this section. See the [classification](implemented/process/2026-06-20-rfc-classification.md) and [index-generation](implemented/process/2026-07-04-generate-rfc-index-tables.md) RFCs. | Class | What it covers | |---|---| diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index b4ee7d83d3..639415ba70 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 [root instructions](../../../AGENTS.md), [documentation standard](../../AGENTS.md), and [RFC format](../README.md#the-file-format); `verify-rfc-format` gates the lifecycle-specific structure. ## 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-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 42efb34774..1c22f9b7ca 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log, Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. -In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary. +In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. ## Alternatives considered @@ -19,6 +19,8 @@ In-session context injection (`context/message`, `steering/message`) renders as ## Consequences -- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). +- Reasoning has a core home without provider-specific shapes. +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). +- Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFCs. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md index c9f07e463e..18923fbb60 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array. +Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array. ## Decision @@ -18,4 +18,4 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required - First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy). - The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them. -- The InferArgs mapping is regression-tested at the type level (expectTypeOf) after an early optionality bug shipped and was caught by review. +- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug. diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md index aeea55103d..4539fb40ba 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -12,7 +12,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end. -Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested). +Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 35c1917926..01e50da2ff 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -6,8 +6,6 @@ Status: implemented Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically. -This is the last of the runtime-validation / error-taxonomy pieces and the one the user was most skeptical of, so it was deliberately built **last and in isolation**: the earlier PRs (arg validation, dev invariants) threw plain `Error`s with a `code` field, decoupled from any shared base, so this change is a pure upgrade and is independently revertible without unpicking them. - ## Decision A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams. @@ -21,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every - Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge. - `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay. -- Reverting this PR returns the earlier errors to plain `Error`+`code` form; nothing else in the stack depends on the shared base. +- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text. diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index 7f30240ffd..0ded28f598 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -22,4 +22,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -Double the adapter maintenance and double the key-gated e2e surface (both adapters cover V4 Flash and Pro across representative thinking/effort modes). Bought: a continuously-verified neutrality guarantee for the most leak-prone abstraction in the codebase, and a worked second example for adapter authors. The two share the core Config shape (`apiKey`/`baseURL`/`models`) so a deployment swaps mostly one line, but the reasoning knob differs — `dsh-llm-deepseek` takes `thinking`/`reasoningEffort`, `dsh-llm-pi-ai` takes a single `reasoning` level — so a swap translates that field. If the maintenance cost ever outweighs the verification value (e.g. once conformance tests from [architectural conformance](../../proposed/process/2026-06-11-architectural-conformance.md) cover the contract mechanically), retiring the twin to a single adapter + the conformance kit would be a new RFC superseding this one. +The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding RFC. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 03d8a30b37..327aa8eac3 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -2,8 +2,6 @@ Status: implemented -> Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices. - ## Problem Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible. @@ -20,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. 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`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. @@ -33,4 +31,4 @@ Format versioning: the header carries a `version`; `load` rejects any non-curren ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes [event-sourced sessions](2026-06-11-event-sourced-sessions.md)'s deferred "real persistence backend" and resolves its `TODO(review)` on the event vocabulary: persisting the log freezes its shape, and the `assistant/chunk` fidelity question is answered above (persist verbatim). +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 10a6209f70..e55cd0853e 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload. -The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. +Failures detected during a turn are logged before `turn/end`. A later flush failure has no valid in-turn position, so it is reported through `agent/error` and logging rather than appended as a session event. This preserves a balanced replay log; durable operational diagnostics require a separate telemetry channel. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 0ecf3e0e78..71efeff826 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -34,7 +34,7 @@ The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-f Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. -Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.) +Read-before-write/edit and observed state belong to `dsh-fs-policy`, not `ctx.fs`. Through the `fs/*` event gate, the policy records versions per opaque actor and supplies optional mutation expectations; the provider enforces freshness atomically. `dsh-tool-fs` emits the events without depending on the policy. See the [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs. ## Package topology 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 d64329262e..665063399d 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 @@ -12,30 +12,28 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker. ### 2. `AgentHandle` async disposer -`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw). +`ctx.agents.create`/`resume` and `AgentFactory` return `AgentHandle = { agent, dispose() }`. Disposal is a consumer capability; an observer holding only `Agent` cannot tear it down. The caller fiber and factory provider also own the instance, and every path shares one memoized teardown: stop the loop, await quiescence and flushes, detach the agent and session, then unwind its scope. IDs become reusable when their registry entries detach. Config-created agents belong to the loop fiber; ACP stores and disposes each session handle. -**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown. +Teardown order is load-bearing for durability. The session lifecycle and loop share one composite Cordis effect so LIFO disposal stops the loop and awaits `agent.done` before detaching the session. Sibling effects would dispose concurrently and could remove append hooks before the closing flush. Disposal notifications are contained so they cannot interrupt the chain. ### 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 -These invariants hold and are pinned by tests: - -- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown. -- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. -- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). -- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. +- ACP disconnect or session close leaves no registered agent or session-store entry, including when `session/load` races teardown. +- Cancelling before a queued prompt starts prevents that prompt from running or absorbing the next prompt. +- Reloading `dsh-tool-bash` does not let another session read or kill an existing background task because ownership remains on the executor. +- Config-created agents remain loop-fiber-owned, so non-ACP demos need not manage handles explicitly. ## Session owner tokens are unique among live agents -The bash owner-token comparison relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. +The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 315771e46e..6dfaa80cd4 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The `Session` event log is the single source of truth ([event-sourced sessions](2026-06-11-event-sourced-sessions.md)), but the only view over it was `deriveMessages()` — a linear scan that filtered and transformed raw events into `Message[]`. This creates problems for session-history-manipulating plugins (compaction, tool-call result pruning, etc.). Without a central mechanism, each plugin would need to wrap `agent/request` to rewrite the message list — a pattern that suffers from listener-ordering fragility, provides no durable record of what was changed, and forces repeated changes to the core `deriveMessages()` whenever a new manipulation is added. A central hub in the `session` package, with a provenance-recording mechanism and enough flexibility for future plugins to manipulate session history through a stable API, lays a solid foundation for plugin development. +The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`. ## Decision @@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). -Because the surface is the SOLE derivation path, a surface-eligible event that carries no `surfaceOp` marker is invisible to `deriveMessages()` — it would land in the log yet silently drop from history on resume/fork. `append`'s typed overload makes the marker mandatory for `SurfaceEventType` events at compile time, but only when the type argument is a SPECIFIC literal; when it widens to the `SessionEventType` union (a caller iterating raw events, e.g. `for (const e of log) append(e.type, e.data)`) the conditional rest collapses to optional and the compiler stops enforcing it. The marker requirement is therefore ALSO checked at runtime in two places: `append` itself throws on a marker-less surface-eligible event (covering the union-widening loophole), and the `Session` seed constructor re-checks the same invariant (alongside its seq-contiguity and JSON-serializability checks) so a seed/load/fork — which arrives as raw `SessionEvent[]`, bypassing `append` — is REJECTED rather than constructing a session that resumes with missing history. (No backward-compat path for surface-less logs: per the pre-release stance there is no persisted user data to preserve, so such a log is rejected, not upgraded.) +Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 5923ff434f..b1773b480d 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -39,4 +39,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Consequences -The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (six methods, no inheritance). The hook surface was deliberately held to the minimum: the create-collision probe is NOT a separate hook — it folds into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements a handful of small primitives instead of copying the entire `session/event` → buffer → flush machinery. +The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 8171a05d30..9f83e46b24 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -6,11 +6,11 @@ Status: implemented The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded IDs in the bash seam.** `BashTask.id` and every executor/tool boundary used bare `string`, even though the generated value has the same `name-N` shape as default session ids. The model also returns this value through `task_id`, so confusing task and session ids was both type-correct and reachable. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — erosion of existing brands.** `CallId`, `SessionId`, and `AgentId` became bare strings in registry maps, public lookup parameters, ACP session tracking, and the persistence coordinator. Dropping a brand at a lookup boundary defeats its main protection. ## Decision @@ -44,7 +44,7 @@ export function OwnerToken(id: string): OwnerToken { ### Why not typing `owner` as `SessionId`? -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The executor treats ownership as opaque and must not depend on the session model. A distinct `OwnerToken` preserves that boundary while preventing raw strings or task ids from being passed as owners. `dsh-tool-bash`, which owns the access policy, performs the single conversion from `SessionId`. ## Out of scope / possible extensions @@ -58,7 +58,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. +`BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded through the executor, local implementation, and model-facing tool without adding a `dsh-session` dependency. Collections, public parameters, and exported signatures use the applicable brand for `CallId`, `SessionId`, `AgentId`, or `BashTaskId` rather than bare `string`; raw provider, ACP, and model inputs enter through the brand factory instead of scattered casts. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 0d8d6c02b4..f3d4178e3b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -6,14 +6,14 @@ Status: implemented An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. -The deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. +The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through `session/new`; stdio requires a console logger and a pre-created `main`. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. ## Decision Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Service map): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension. -- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. +- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. +- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. - **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. @@ -38,10 +38,10 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Verification -- Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. -- `demo:echo` / `demo:repl` / `demo:acp` run via the app-package `bin`s. -- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). -- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record. +- Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone. +- `demo:echo`, `demo:repl`, and `demo:acp` invoke the app-package bins. +- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). +- The ACP replay transcript remains unchanged because the plugin set and load order did not change. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index df4914f105..102a29d613 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -12,7 +12,7 @@ The immediate prompt came from OpenRouter's [App Attribution](https://openrouter - **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard. - **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers." -- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them. +- **Coding agents identify the product and version in `User-Agent`.** Public implementations vary in environment detail and provider-specific side headers, but product identity is the common contract; there is no universal exact format. - **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request." - **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header. - **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 660b9821ff..40a95a7100 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -22,7 +22,7 @@ Web access is a first-class capability seam following [the capability-seam RFC]( Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. -Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. +Search and fetch are separate tools but one web-access seam. `ctx.web` owns provider selection, abort/error vocabulary, and deployment configuration for both parallel registries. Their request schemas and provider logic remain separate; the shared service is the product boundary for reaching the web. `dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: @@ -66,7 +66,7 @@ flowchart LR `@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with platform-native `fetch` at the repo's Node floor, mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. +Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -239,7 +239,7 @@ type WebFetchBody = `WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair. -`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim). +`WebFetchBody` is a closed discriminated union because body kinds require coordinated changes to the seam, provider, and tool rather than independent plugin extension. Exhaustive switches make a new kind fail compilation at every renderer until handled. Separate object arms leave room for kind-specific fields. The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index c890acb5c0..486d9aafb4 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. @@ -68,9 +68,7 @@ The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). -**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. - -**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider. +**The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall. The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. @@ -124,7 +122,7 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. +**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract. ## Policy plugin contract (`dsh-fs-policy`) @@ -154,7 +152,7 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Verification -The decoupling and its semantics are pinned by tests: a bare-provider config (no `dsh-fs-policy`) boots the `dsh-tool-fs` root plugin and `read`/`write` (create and overwrite)/`edit` work against the real `dsh-fs-local` — an unread edit and an unread overwrite both succeed, proving the tool carries no `fileContext` dependency, while the same operations with `dsh-fs-policy` present are rejected `FS_NOT_OBSERVED` / gated `createIfAbsent`. A second `fs/edit-intent` listener registered after `dsh-fs-policy` is asserted NOT reached (first-wins short-circuit). A stale-read edit reports `FS_STALE_VERSION` through provider CAS, with `dsh-fs-policy` performing no `stat`; the tool's `stat` budget (read = 1, write = 0, edit = 0, on both paths) is asserted directly. Model-facing schemas stayed byte-for-byte unchanged, so snapshot transcript goldens are unaffected. +Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change. ## Alternatives considered 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..de6428fb64 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 @@ -14,18 +14,18 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq Three deliberate choices: -1. **The model-facing `bash` tool simply does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only; a model that includes `env`/`stdin` keys in its tool-call arguments simply has them ignored. A regression guard (`tool-bash` "does not forward env/stdin" tests) drives the real tool with those extra args and asserts the recorded request carries neither field — its purpose is to catch a future refactor that blindly spreads `...args` into the request and silently starts forwarding model input into the post-scrub `env` merge, NOT to defend a trust boundary. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it). +1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. 2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. -`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. +`dsh-bash-local` creates a stdin pipe only when bytes are supplied; otherwise fd 0 remains `/dev/null`, preserving prior behavior. It writes the bytes and closes the pipe. `EPIPE` from a child that exits without reading is ignored because command exit and output determine the result. ## Alternatives considered -An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a speculative surface with no consumer. If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then. +**Configurable ambient-secret scrub.** Rejected as speculative. Trusted callers can explicitly provide required values after the scrub without weakening the default ambient protection. ## Consequences -A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged (the credential scrub, not these fields, is what bounds it), and the `bash` tool's request-building stays the single place that decides which fields a model call carries — guarded by a test that fails if a refactor starts forwarding model input. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs. +Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../core-data-structures/bash.md). diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index f0ab95ca80..4cf055179c 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -12,7 +12,7 @@ The harness extends the agent loop through a Cordis event taxonomy (see [the mic Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. -This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the `hook/*` durable log, the bridges) build on. +This vocabulary is the foundation for interception decisions, the durable `hook/*` log, and the Claude Code and Codex bridges. ## Decision diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md index d0656c9327..fad851265d 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -6,7 +6,7 @@ Status: implemented The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. -The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide. +Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical. ## Decision diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 45d5a77e46..218cb29297 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -22,7 +22,7 @@ Add a **persisted, tool-private presentation channel** so a tool's `execute` can type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } ``` -`meta` is an opaque payload the core never interprets — typed `unknown` at every seam (the tool that produced it owns and narrows its shape). It MUST be JSON-serializable: the registry threads it onto the `tool/result` **session event**, and `Session.append` runtime-validates all event data with the existing `isJsonValue` predicate, so a non-serializable `meta` is rejected at the source. On replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. Typing `meta` as `unknown` (rather than a shared serializable-value type) keeps the tools core free of a dependency it would otherwise take just to name the type, and the runtime `isJsonValue` gate — not the static type — is what actually enforces serializability. +`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core. This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. @@ -31,7 +31,7 @@ This is the general shape ("a tool attaches durable result presentation"), not a Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: - `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. -- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A contextual hunk is computed only when a before-version exists — edit always; write on overwrite; a create has no before, matching `claude-agent-acp`'s empty `structuredPatch` on create. But the completed `tool_call_update` is ALWAYS a `diff` card for a successful mutation: an ACP `tool_call_update.content` REPLACES the call's content, so rendering the model-facing result text would clobber the pending diff. So `write`'s result falls back to an args-derived whole-file diff (`oldText: null`) when it has no contextual hunk (a create, or an overwrite whose content is unchanged), and `edit` — which always changes content — always has a hunk. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and falls through to the generic error rendering (its message must show). +- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. ### 3. The bridge renders a `diff` result card @@ -39,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ## Alternatives considered -**Hand-rolling or vendoring the diff algorithm.** Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). +**Hand-rolling or vendoring the diff algorithm.** Contextual hunks have established edge cases, so `dsh-tool-fs` uses the typed [`diff`](https://www.npmjs.com/package/diff) package and normalizes `structuredPatch` output in one module. The repository's vendoring policy applies to its framework source, not every leaf utility. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md index 050c0f3590..451cce45d3 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -8,7 +8,7 @@ Status: implemented The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `/SKILL.md` or `.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack. -This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation. +This decision adds the provider capability without a model-facing `ls`/`list` tool or skill-discovery change. Those consumers require separate UX, prompt, and policy decisions. ## Decision @@ -36,7 +36,7 @@ Broken or disappeared children may be represented as `type: 'other'` without `ve ## Alternatives considered -**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately. +**Add a model-facing list tool with the seam.** Rejected because its prompt, schema, and rendering contracts are independent of the provider primitive. **Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 0961830c1a..854807c109 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -24,21 +24,21 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### Persona as the order-0 section -`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership -Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. +Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior. ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered @@ -56,10 +56,10 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## Shipped invariants -- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. -- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. -- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. -- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. +- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. +- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. ## Consequences 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 0b82e52581..d95a709ada 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design. +The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded. The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing. @@ -20,22 +20,22 @@ 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 system changes by common-prefix/suffix line trim, tools by name-keyed additions/removals/changes, and config or prefix by full replacement. `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 for unrepresentable changes such as pure tool reordering. -**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 prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from 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, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `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. +**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written. -**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. +**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted -What survives from `LLMClient`: the conversation is maintained, not rebuilt — one projection per message, ever; requests advance append-only; resets happen only for a system-prompt/tool change, a config change, or compaction, each now a *logged* fact. What is deliberately inverted: MiniCode's client is the source of truth and its event stream derives from client appends (`on_event(MessageAdded)`), which suits an advisory event stream. Here the log is contractual — persistence, crash recovery, fork seeding, transcript rendering, and the snapshot harness all replay it — and it carries strictly more than a message list (turn/step boundaries, raw chunk streams, tool-call pairing, provenance, log-only records), so a message-list client cannot generate it. The arrow therefore points log → client: the conversation state IS the log plus two cached folds inside `Session` (messages, header), and the "client" the loop talks to is the session itself. What the inversion buys over the original: the reconstruction is *checkable* against an independent record on every request — MiniCode's client has nothing to check itself against. +Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and provenance. `Session` caches message and header folds derived from that log, making every request independently checkable. ## Alternatives considered - **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above. -- **A stateful transmission client mirroring the log** (a `PromptPrefix` class holding committed/open message zones with an append/editTail/reset vocabulary, the log pushed into it per event): behaviorally equivalent on the happy path, but it duplicates conversation state outside the session, needs transactional rollback around listener seams, keeps an unlogged content-shaping surface (`editTail`) whose divergence the invariant must specially allow, and still cannot answer "what header did the model see" from the log. Dissolving it into the session's own caches plus logged header events made every one of those problems unrepresentable instead of guarded. (PR #162 is the archaeology of this alternative, three designs deep.) +- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths. - **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records. - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 46e7ea4e71..30674f8c8b 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -6,7 +6,7 @@ Status: implemented [The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. -The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). +Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). ## Decision @@ -21,7 +21,7 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis ## Alternatives considered -- **Resolving the provider at `apply` time and throwing when absent (a load-order requirement)** — the first implementation, rejected after review reproduced the failure above. Documenting the requirement ("list backends first") would pin a guarantee the Loader does not make. +- **Resolve the provider at `apply` time and throw when absent** — rejected because "list backends first" would claim a Loader ordering guarantee that does not exist. - **Retrying the lookup (poll until the provider appears)** — converges eventually but invents a private readiness protocol beside the one the framework already has (effect registration + disposal); it also cannot notice a provider LEAVING, so HMR would strand a tool whose wording describes a disposed backend. - **Section-only subagent wording, lazily resolved at assemble time** — tolerates any load order too, but moves tool-choice guidance out of the DESCRIPTION, contradicting the ownership rule the prompt-variables RFC establishes (per-tool semantics and when-to-use belong in the description). Reactive registration keeps the description authoritative AND order-free. - **Keying wording off the provider NAME instead of the provider object** — `providerName` is itself config, so a renamed provider silently gets the wrong words; deriving from the resolved provider's own `inheritsParentContext` cannot drift. @@ -29,6 +29,6 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis ## Consequences - Consumers deriving state from a named provider react to `subagent/provider-added`/`-removed` instead of reading the registry at `apply` time; `dsh-tool-subagent` is the reference implementation. -- **The two emits carry asymmetric failure semantics, deliberately.** `provider-removed` fires inside the registration's disposer and is delivered with PER-LISTENER containment (the service's `emitLifecycle`, not raw `ctx.emit`, which halts dispatch on the first throw): a throwing subscriber is logged, never starves a later mirror into holding a stale tool, and never disrupts the backend fiber's teardown — dispose reaches quiescence. `provider-added` propagates: 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. The run-time backstop bounds what a stale mirror could cost anyway: `start()` re-resolves the provider by name per run, so a tool that outlived its provider fails that call cleanly instead of dispatching into a dead backend. The [events catalog](../../../cordis-catalog/events.md) carries the exact signatures, and the [producer/consumer map](../../../event-producer-consumer.md) shows `dsh-subagent` emitting and `dsh-tool-subagent` consuming both events. +- **Addition fails loud; removal is contained per listener.** An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown. `start()` still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the [events catalog](../../../cordis-catalog/events.md) and [producer/consumer map](../../../event-producer-consumer.md). - **A window where the tool is absent.** Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's `tools/change` emit keeps prompt assembly current. - **Two waiting fibers sharing a `toolName` is an invalid config caught late.** If two loads of `dsh-tool-subagent` name different providers but the same `toolName`, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives. `TODO(subagent-dup-toolname)` in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop. 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..7aa987c60f 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 @@ -12,8 +12,6 @@ Timeout handling was drifting apart across the tool-bearing capabilities, and th Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash kills an OS process group (work runs in a child process, outside this runtime, reachable only by signal), while web aborts an in-process `fetch` (undici tears down the socket). There is no single mechanism that can stop all of them. -The two reference agents surveyed converged on the same split. Codex models "what will end this exec early" as one value (`ExecExpiration`, an enum fusing timeout and a cancellation token) whose `wait_with_outcome()` returns `TimedOut | Cancelled`, while the actual `kill_process_group` lives outside it — and that abstraction is reused *only* across the exec family, with MCP, model-stream, and guardian each keeping their own bespoke `tokio::time::timeout`. Claude Code shares nothing: bash and ripgrep each own a private SIGTERM→SIGKILL kill and distinguish timeout from cancellation by throwing distinct error types, while file I/O has no timeout. Both confirm the boundary drawn here: the timing-and-classification half is worth sharing within a family of like-terminated operations; the termination half is not shareable and stays in each capability. - ## Decision `@deepseek-ai/dsh-timeout` lives under `packages/util/` (peer to `dsh-brand`) and owns the *timing and classification* half of timeout; the *termination* half — the hard kill — stays in each capability's implementation. It is a library of pure functions, **not** a cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. There is deliberately no central "timeout service" that would have to know how to stop every capability's work — that knowledge is exactly what a microkernel keeps out of shared layers, and what Codex's exec-only `ExecExpiration` scope demonstrates. @@ -57,7 +55,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 @@ -76,7 +74,7 @@ The signal only *notifies*; termination is always the listener's job, and the li ### How each capability consumes it - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. -- **bash** — `resolve()` stays a pure request-to-spec step: it clamps with `clampTimeout(request.timeoutMs, config.timeoutMs, config.maxTimeoutMs, 'bash-local: request.timeoutMs')` and carries `request.signal` through unchanged. Foreground `run()` owns the timeout: `using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')`, then `runBash` receives only `d.signal`. `runBash` no longer owns any timer — it listens for abort and runs its existing SIGTERM→grace→SIGKILL process-group kill, and its `SpawnSpec`/`SpawnOutcome` no longer carry `timeoutMs`/`timedOut`/`aborted` (the executor classifies from the deadline signal instead). `run()` computes `timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined` and `aborted = d.signal.aborted && !timedOut`, so the public seam booleans (`BashRunResult.timedOut`/`aborted`) are mutually exclusive — the shared deadline reports the cause that first cut the command short, and the `code` scope keeps a nested outer deadline from being misread as bash's own timeout. Background `start()` creates no deadline and forwards only the upstream signal, so background tasks stay timeout-free; a task's killed-vs-completed status reads its own `spec.signal.aborted`. +- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. ## Consequences @@ -95,4 +93,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep bash's two independent triggers (`killTimer` + `onAbort`) rather than fusing.** Rejected for the convergence goal: fusing into one `deadline` signal removes bash's bespoke timer and gives every capability one shape. The trade-off is that bash's `timedOut`/`aborted` booleans become first-abort classifications rather than independent facts that can both be true when timeout and user abort race before process close. That is acceptable because the result reports the cause that first cut the command short; the termination action stays the same uniform SIGTERM→grace→SIGKILL kill. Note the deliberate non-alignment with Codex: Codex forks its kill by outcome (timeout → immediate SIGKILL; cancel → SIGTERM + 50 ms grace → SIGKILL), whereas the fused signal drives one uniform `kill()` for both, matching Claude Code's unified bash kill. Splitting the kill by `timeoutOf` is possible later if a need appears; there is none now. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index ab6be2efc3..ed0a9a2cb4 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -50,7 +50,7 @@ The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespac searchTimeoutMs: 30000 ``` -Keeping the tool name out of this plugin's config is deliberate: a budget keyed by a free-text tool name could be mistyped (`web_fech`) and then silently apply to nothing. Declaring `timeoutMs` on the tool makes that failure class structurally impossible — the enforcer reads `ctx.tools.get(exec.name)?.timeoutMs`, and `exec.name` is the tool being dispatched, so the lookup always resolves and there is no unknown-name path to warn or throw about. `timeoutMs` is validated positive-finite by `defineTool` at definition time. For a tool that declares a budget the listener arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')`, swaps the derived signal onto `exec` for the downstream dispatch, restores the caller's own signal afterward, and returns a structured `TOOL_TIMEOUT` result when `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches. A tool with no declared budget delegates unchanged. +Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal, restores the caller signal afterward, and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so the documented cordis idiom — mutate the shared object, then delegate — is the only mechanism that reaches dispatch. The plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees this plugin's (possibly already-aborted) deadline signal. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index db8c7eb8ee..bc3c0403c2 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4170d9f63773ef998bc5393fabc98a2b9fcba2fa -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7d80fb572ea36010df4829121fabe8cf66944f6b +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 372058dc04c4a36e82f5a5a6f5ef1af48068e4e3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cd12a65d185e8cdeafc4d04faad4a3349c6150d4 diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 4170d9f637..372058dc04 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,7 +42,7 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 7d80fb572e..cd12a65d18 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,7 +42,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包和 3 个原生运行时 wheel 包;裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 063cc4738c..f09ab214f5 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -26,7 +26,7 @@ The design can be skimmed as seven choices: | Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result | | Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary | -The rest of this RFC expands those choices in dependency order. It first explains the Cordis mechanics, then scope routing, creation and session commit, tools and prompts, subagents and workflows, and finally the checks that make the reasoning executable. +The rest of this RFC expands those choices in dependency order: Cordis mechanics, scope routing, creation and session commit, tools and prompts, subagents and workflows, then executable checks. The [July 8 RFC](2026-07-08-agent-scope-contexts.md) remains the contributor contract. The separate [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns `persona`, `toolFilter`, and `maxDepth`; this document discusses only how their setup fits the lifecycle. diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index 317d244d29..bfd0e6a10e 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -24,7 +24,7 @@ Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentRe Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. -The bridge advertises ACP config options instead of session modes. When `ctx.permission` is composed, it exposes one `permission` select whose options come from the deployment's preset table; the shipped presets are `workspace-write` and `danger-full-access`, and each bundles a sandbox mode with an approval policy. The current value comes from `PermissionService.current()`, including the derived switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` validates through `PermissionService.set()` and writes the chosen preset through to both owning knob events. An open-turn switch appends immediately; an idle switch is overlaid in the response and anchored at the next turn start. Until that anchor it is memory-only and a crash reverts to the durable fold. ACP session modes are deliberately not modeled because config options are the forward protocol surface. Runtime model selection remains outside this decision; `AcpConfig.model` is connection-wide. +When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide. The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. @@ -50,7 +50,7 @@ Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, runtime model selection, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. The feature checklist records these as unsupported rather than silently accepting them. -An idle config selection is truthful in the live response but not durable until the next turn anchors it. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe. +An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe. ## Verification 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 60acfa7c88..950c2b4bd7 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -24,33 +24,33 @@ Three decisions, each elaborated in its own section below: `ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list = the registry's contribution before cooperative assembly.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the final presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed there, and cannot be named by `ctx.tools.restrict()`. The mode governs this provider's input to assembly; other direct `systemPrompt.tools()` providers own their schemas, and the trusted assembly waterfall owns the returned wire list. +**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. -**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text. +**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. **Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. -**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. +**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. ### The run_code tool and the dispatch bridge Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. -3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results. +3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. -**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. +**Sub-call `additionalContext` is omitted.** Injecting it during `run_code` would break parent call/result adjacency, while one program can produce many contexts. Supporting it requires a plural channel or loop-level sub-dispatch buffer. -**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe. +**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata. **Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. ### Observability: `tool/code-dispatch` -Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. +Each sub-dispatch appends a log-only `tool/code-dispatch` event containing parent and child call ids, tool identity, normalized arguments, and result summary. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. ### The code-runtime seam @@ -63,7 +63,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's optional-backend idiom: Cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk. The seam has concrete divergence on both axes: the worker-thread substrate can be replaced by a container or microVM implementation, and the TypeScript language contract can be paired with a language-specific SDK and runtime. `dsh-tools` consumes only the interface and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. +Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. ### The worker-thread runtime @@ -73,36 +73,27 @@ Per explicit-over-implicit at seams, the request spells out everything the runti 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. 3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented). 4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. -5. **Enforce caps — two independent budgets, because the peer is hostile.** The compute budget (`computeMs`) meters the worker's *measured busy time* via `worker.performance.eventLoopUtilization()` polling — not host-side "is an RPC pending" bookkeeping, which a program defeats by firing an un-awaited call at a slow tool and then spinning hot while the host thinks it is waiting. Measured busy time cannot be gamed: a hot loop accrues it whether or not a dispatch is in flight, and a program genuinely awaiting a slow tool accrues none, so a long-running `bash` sub-call still does not kill an innocent run. The wall ceiling (`maxWallMs`) never pauses for anything and backstops what busy-time cannot see (a program awaiting a promise nobody will resolve). Budget expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap); the failure reports which budget fired. Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config, truncation marked in-band. All caps are validated config fields with defaults (`computeMs: 60_000`, `maxWallMs: 600_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`. +5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. Expiry, cancellation, and completion terminate the worker. Heap exits and truncation are reported explicitly; compute, wall, heap, log, and return-value caps are validated configuration. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../defensive-patterns.md). ### 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 -The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program is the body of an async TypeScript function (erasable syntax only — no `enum`/namespaces; type annotations are advisory); call tools as `await tools.name(args)` (quoted access for exotic names); a failed tool call **rejects** with an `Error` carrying the tool's error text — catch it to handle and continue; calls run **sequentially** even under `Promise.all`; emit results via `return` and/or `console.log`, and only that curated output returns to the context — intermediate tool results never do. That last line is the payoff the whole design serves: output-side context cost becomes the model's own editorial decision. On the input side the `.d.ts` is not free — for a large tool surface it can rival the native JSON schemas it replaces (and `'both'` pays for the two side by side) — but it is prefix-stable, so provider prefix caching amortizes it; the win is workload-dependent and the RFC claims no more. +The SDK instructs the model to write an async erasable-TypeScript body, call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. ## Consequences -The design consists of the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` presentation and dispatch integration. - -Shipped surface: - -- **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. -- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, lazy `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). -- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution. -- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the registry-owned presentation transport, while assembly listeners may rewrite the final model-visible surface and own its protocol integrity; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. +Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, and the bridge does not propagate per-call `additionalContext` until those contracts are designed for Code Mode. ## Testing -What the suites pin, per tier: - -- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). -- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, scoped shadowing, authoritative assembly transformation, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety. -- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. -- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. +- **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node. +- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, omitted `additionalContext`, and HMR cleanup. +- **With-key e2e:** A real model composes two bash calls in one program; the test verifies the collapsed request header, correlated dispatch events, resulting file, and curated answer. +- **Snapshot:** The `code-mode-turn` and `both-mode-turn` fixtures pin the SDK section, header tool list, dispatch events, and result card. ## Alternatives considered @@ -132,7 +123,7 @@ What the suites pin, per tier: **Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. -**Structured-clone limits at the binding boundary.** The seam's clone boundary admits values JSON does not (`Date`, `Map`, `BigInt`), and the session log accepts only JSON — left unhandled, a sub-call could execute and then fail at `tool/code-dispatch` append time. Closed by the bridge's JSON-normalization step (§ the dispatch bridge): what does not survive the round-trip rejects that binding call before dispatch, so every executed sub-call is loggable by construction. The seam itself keeps the wider structured-clone contract (it is about the port, and stated so a future binding producer cannot discover it in production); consumers with stricter payload needs enforce them at their own boundary, as the bridge does. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions. +**Structured-clone values can exceed JSON.** Tool bindings therefore JSON-normalize arguments before dispatch, ensuring every executed call can be logged. The lower-level runtime keeps its wider port contract, while stricter consumers validate at their boundary. Non-text sub-results become placeholders. **Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index 5eb38f1f99..3b8cd810a8 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -6,7 +6,7 @@ Status: implemented The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. -That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.) +Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card. ## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` 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..4a6586b159 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 @@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam -Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. +Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change. The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): @@ -46,15 +46,13 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface. - -This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. +The loop derives messages once after `agent/pre-step`. Running before `step/start` keeps compaction records outside any half-open step, simplifying crash repair. The seam is awaited and serial so surface mutations cannot interleave; listeners return `void` and do not use Cordis bail values as vetoes. ### Retention is turn-agnostic; tool-pairing balance is the only structural guard 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. @@ -62,7 +60,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Head-anchoring: one auto checkpoint, always at the head -`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) +Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints. ### Approximate convergence invariant @@ -85,7 +83,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### Checkpoint framing + incremental merge (backend-private) -The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all. +The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary. ### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy @@ -121,7 +119,7 @@ Two failure paths, both documented: ## Testing -- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. -- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request. -- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. -- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn. +- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request. +- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. +- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 652eac5522..e2871532c8 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -37,7 +37,7 @@ A new package group `packages/subagent/`: ### The primitive: async `start → SubagentRun` -A provider exposes `start(request) → Promise`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event. +A provider exposes `start(request) → Promise`. Fulfillment publishes a ready child and transfers its run handle to the caller. One signal covers cancellation before and after readiness; `dispose()` cancels remaining work and awaits quiescence. A rejected start cleans partial resources and emits no lifecycle event. `start` is transport-neutral; `spawn` names only the fresh in-process backend. ### Two kinds of optional capability, discovered two ways @@ -46,7 +46,7 @@ A provider exposes `start(request) → Promise`. Promise fulfillmen ### Fork vs. fresh are separate backends, not a flag -Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn that the [invariants](../../../../packages/support/invariants/src/index.ts) trace replay rejects. +Fresh and forked children are separate providers, not a request flag. `dsh-subagent-spawn` starts an isolated child; `dsh-subagent-fork` seeds a balanced prefix containing only completed parent turns. The in-flight turn is excluded because its subagent call has no result yet and cannot form valid replay history. ### Child isolation and the parent log @@ -54,7 +54,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p ### Synchronous collect (first cut) -The `dsh-tool-subagent` consumer passes its execution signal into the start request, awaits the ready run's `result`, and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. A `try/finally` always `dispose()`s the run, so no success, failure, or cancellation path leaks an idle child/session. A non-`completed` stop reason maps to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but intentionally unused in this consumer. +`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run in `finally`. Non-completed outcomes become error results rather than successful partial output. This foreground consumer does not use the run's optional steering method. ### Provider selection is config, not model-facing @@ -62,7 +62,7 @@ The `dsh-tool-subagent` consumer passes its execution signal into the start requ ## Testing -The seam is tested through the real cordis Loader / export path, not a hand-built `ctx.plugin` mount (which bypasses `unwrapExports` and cannot catch a broken export shape — [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)); the registry pins HMR-safety, duplicate-name rejection, and start-time capability rejection; the nested-agent snapshot scenarios replay keyless in the default gate ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); in-process backends carry real-loop unit tests plus a with-key e2e. +The seam is tested through the real Cordis Loader/export path, which catches the export-shape failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. ## Consequences @@ -70,4 +70,3 @@ The seam is tested through the real cordis Loader / export path, not a hand-buil - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. -- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`. It was built single-session: a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and a harness that harvested a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needed per-session-keyed replay plus harvest-all-logs and plural-session-id plumbing — self-contained infrastructure orthogonal to the backends, scheduled as a dedicated stacked follow-up rather than folded into the in-process-backends PR. That follow-up has **landed**: see [Per-session snapshot replay for nested agents](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). Replay now keys each call by its calling session (`GenerateOptions.sessionId`) and binds live sessions to recorded scripts by first-call order; the harness harvests every log; and two nested scenarios (`subagent-spawn`, `subagent-multi`) replay keyless in the default gate. In-process subagents remain covered by real-loop unit tests and a with-key e2e in addition to the snapshot tier. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 518f9dedef..0b98adb0de 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -32,17 +32,15 @@ The child is a separate process, so it inherits an environment. Credential-shape ## Testing -Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time: - -- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Coverage includes the prompt round-trip and output accumulation; every StopReason mapping; cancellation through the required request signal and through disposal; already-aborted and cancel-races-ahead-of-newSession starts; a torn pipe after cancellation settling `aborted`; permission auto-answer under both policies; non-message updates; nonexistent-command startup failure with process reaping; provider HMR; and the namespace export shape. -- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. -- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. +- **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports. +- **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file. +- **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child. ## Alternatives considered -### Why not the 0.28.x SDK bump? +### Why stay on SDK 0.25.1? -The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. +The backend needs only `ClientSideConnection`, `ndJsonStream`, `PROTOCOL_VERSION`, and the client protocol types, all supported in 0.25.1. The 0.28 fluent API would require migrating both client and server connection classes across the ACP layer without improving this backend, so that upgrade remains a separate change. ### Why not a persistent child process? diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index 90bd274eb3..126c69f1c0 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -32,7 +32,7 @@ claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew i ### Single owner — no swarm machinery (YAGNI) -The list belongs to the ONE agent session that called the tool (`exec.agent.session`); a non-agent caller is rejected. There is deliberately no shared/multi-owner scope, no capability seam (interface/impl/consumer), no scope resolver, and no delta protocol. The harness does have subagents, and a shared cross-agent list is conceivable — but building that now means designing for a form the product does not yet have. The whole-list-replace + single-owner shape is what claude-code V1, opencode, and codex all ship; if a shared list is ever needed, the on-log representation would change to per-item deltas (so concurrent writers can't clobber each other) and a scope resolver would choose the target log. That is a future RFC, not speculative scaffolding today. +Each list belongs to the calling agent session, and non-agent calls are rejected. There is no shared scope, resolver, or delta protocol. Cross-agent lists would require per-item log deltas and explicit scope selection, so they remain a separate future design. ### Validation: the cheap middle 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 3090aa641a..2d285fb152 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -33,11 +33,11 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de ### Context source is always the plugin (the mislabel guard) -`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. +`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }`, so every bridge `inject()` and `HookContext` passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `context/message.source` as the plugin rather than the user. ### 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 @@ -49,7 +49,7 @@ The config is parsed ONCE at load; a read/parse failure logs and registers nothi ### Where hooks run, and where their config comes from -Two different cwds, kept distinct on purpose. The hooks **themselves** run in the agent's **session workspace**: for the agent-scoped points the bridge threads the session's `cwd` (`session/new.cwd`, on the session header) to `runHook` as the process working directory, so a hook's `pwd` / relative-file read / marker write operates in the user's project tree, not the server's launch directory. The **config path**, by contrast, is **process-level**: `configPath` is resolved and parsed once at load against the process launch cwd, so a single `hooks.json` applies to the whole process — there is no per-session config discovery that reads a project-local `hooks.json` from each `session/new.cwd` (`TODO(per-session-hook-config)`). This is an honest limitation of the current cut: the example `cordis.yml` documents that its `./hooks.json` is process-level, not per-project. +Hooks run in the agent's session workspace, so relative paths target the user's project. `configPath` is resolved once against the process launch cwd and applies to every session. Per-session project-local discovery remains deferred under `TODO(per-session-hook-config)`. ## Deferred compatibility gaps @@ -57,7 +57,7 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th - **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking lands. - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). -- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` 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). `subagent/start` is emitted only after child publication, so the bridge can capture the live in-process child synchronously, but the result driver may queue the prompt as that same readiness boundary resolves and a short-lived child can finish before the detached hook injects. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. +- **Session-start / subagent-start context is best-effort (`TODO(session-start-gating)`).** Both hooks run detached from startup, so their context is injected when ready but may miss the first request or a short-lived child. Guaranteeing first-request delivery requires an awaited startup seam. ## Alternatives considered @@ -65,4 +65,4 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th ## Consequences -The bridges are thin and readable standalone: the correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in the shared `dsh-hook-protocol`, so each bridge is just config-parse + payload-build + outcome-map. Each is covered at per-file 100% — config-parse branches as unit tests, and the seam mappings end-to-end through the REAL loop + REAL `dsh-bash-local` + REAL shell scripts from a temp `hooks.json` (a scripted mock MODEL is the only stand-in), plus a real-Loader export-shape guard so a stray default export can't silently drop `inject`. Because the seams already carry typed Decisions, a future native plugin needs none of this bridge machinery — it returns a Decision directly. +Matcher semantics, exit-code handling, and merge precedence live in `dsh-hook-protocol`; each bridge only parses config, builds dialect payloads, and maps outcomes. Per-file coverage includes config branches plus end-to-end mappings through a real loop, `dsh-bash-local`, and shell scripts, while a real-Loader smoke guards the package export shape. Native plugins bypass the wire protocol and return typed decisions directly. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index fa83dbd779..ac28345791 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -23,8 +23,8 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Alternatives considered -**One parameterized engine.** A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing. +**One parameterized engine.** Rejected because payload construction and decision mapping genuinely differ by dialect. Matchers, codecs, execution, merge rules, and events remain shared; each bridge keeps its payload and mapping explicit so its wire behavior is readable in place. ## Consequences -The two bridge plugins become thin: parse the config file, pick a matcher mode, build the per-event payload+env, call `runHook` + `mergeHookOutputs`, map the outcome to a Decision, and append `hook/*`. The protocol's correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in one tested place — `hook-protocol` ships with heavy unit tests (matcher per-mode, codec per exit-code/field, runner plumbing with a stub executor, merge precedence, the `hook/*` helpers) at per-file 100%. Input rewrite (`updatedInput`) is parsed but not honored (the deferred [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs+warns on it. The package is a library, so it has no `cordis.yml` load path of its own — its real-load-path coverage comes through the bridge plugins that consume it. +Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index cb653c2519..ea371ad55a 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -20,7 +20,7 @@ The canonical surface separates transformable policy, around-dispatch control, a ### The tool pipeline gives each phase one kind of authority -Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry reads each caller-owned input field once, materializes `arguments` as detached lossless JSON in one recursive pass, and snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token. Identity fields and deeply frozen arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran. +Every call follows `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`. The registry snapshots caller input, materializes and freezes arguments, and assigns an opaque token. Nested calls carry only the parent token. Identity remains immutable; only `signal` may change around dispatch. The log, UI, and tool body therefore agree on what ran. - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. +1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. @@ -42,7 +42,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Pre-tool input rewrite is a separate consistency decision -`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the materialized arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`. +`PreToolDecision` cannot rewrite arguments. History and the audit call are logged before execution, and ACP presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md). ### Boundaries diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index bf366afc02..8d8813563b 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -24,7 +24,7 @@ class SessionStore extends Service { `boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. -The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). A boundary that is not an existing event seq, is not a safe integer, or does not point at `turn/end` is rejected with a typed `SessionForkError` code. Broader session-log sanity remains in the existing invariant/repair layers: `dsh-invariants` checks turn enclosure and richer event ordering in dev, while persistence repair handles the valid crash-tail case of a final interrupted turn. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`). +An empty prefix is forkable; any non-empty boundary must be a safe existing sequence at `turn/end`, regardless of reason. Typed errors distinguish missing sources, stale objects, duplicate child ids, and invalid boundaries. Broader log validation and crash repair remain with their existing owners. ## Alternatives considered 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 11e52eb8de..6302584103 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int ### The script contract (Claude Code-compatible) -A workflow call is two parts: a `meta` JSON parameter (the identity block — `name`, `description`, optional `whenToUse`/`phases`; the field vocabulary matches Claude Code's meta block) and a `script` — a plain-JS body with top-level `await`, ending in `return `. Meta is DATA, never code: the engine shape-validates it and evaluates no script text to obtain it (a body still opening with a CC-style `export const meta` statement is rejected with a pointed message). The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored BODY runs unchanged (its meta header moves into the parameter) while scripts written here may freely read the clock. +A workflow call contains JSON `meta` (`name`, `description`, and optional `whenToUse`/`phases`) and a JavaScript `script` body with top-level `await` that returns a JSON value. Metadata is validated as data and never evaluated. The body receives `agent(prompt, options)`, `parallel(thunks)`, `pipeline(items, ...stages)`, `phase(title)`, `log(message)`, and `args`. Pipeline stages receive `(prev, item, index)` with no cross-stage barrier; failed children and ordinary stage errors resolve the affected item to `null` and skip its remaining stages. Claude Code's determinism restrictions are deferred with journaling, so compatible bodies may use clock and randomness after moving their meta header into the parameter. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. @@ -22,15 +22,17 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-workerthread): one worker thread per run -**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. +**Trust premise**: workflow scripts have the same trust as the model's bash access. The engine contains buggy scripts and guarantees settled results, JSON-safe values, and cancellation quiescence; it does not defend against hostile code. A vm context and worker thread are not security boundaries: a script can escape to Node APIs with process-wide authority. Sandboxing requires a separate-process or isolated-vm engine behind this seam. -**Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. +**Why `node:worker_threads`**: each run gets one unpooled worker. A vm context limits the documented script surface, while message-port RPC bridges `agent()` to host-side child loops. The worker prevents synchronous script work from blocking the host, provides a serialization boundary, and permits forced termination after cancellation. `isolated-vm` was rejected because of its maintenance state and deployment requirements. -Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.cjs`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. +The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol; pending starts, published child records, one cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across it. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. -**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. +The engine exposes an in-process `MessageChannel` test path because main-process V8 coverage cannot see worker execution. -**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. +**Meta is data**: the schema-validated `meta` field reaches the seam as JSON and is only shape-validated. The host never evaluates a metadata literal, which would let script-controlled accessors run outside the worker's isolation. + +**Value boundary**: `materializeFromRealm` copies outbound values and rejects functions, symbols, nested `undefined`, exotic prototypes, cycles, sparse arrays, and non-finite numbers. Data-property copies make `"__proto__"` safe; getters are read normally and a throwing getter fails loudly. `args` crosses through `workerData` and is cloned again before exposure. Realm functions are invoked rather than copied, and thrown values use a total renderer so `result` cannot reject. Hook errors are host-realm `WorkflowError`s, so scripts branch on `name` or `code` rather than `instanceof Error`, as documented in the engine README. Concurrency, total-agent, item, timeout, and grace limits are validated config. ### The consumer (dsh-tool-workflow) @@ -44,6 +46,10 @@ An output schema makes a schema-valid committed capture mandatory for successful `StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms. +## Testing + +Worker-side logic runs through an in-process `MessageChannel` so V8 coverage measures it. Unit tests cover script helpers, fatal and nullable failures, JSON boundaries, caps, cancellation, child ownership, and structured output through real loops. A built-bin smoke runs the separately bundled `lib/worker.cjs` under plain Node, a with-key e2e drives real child agents, and model-facing workflow behavior is snapshot-covered through its owning example. + ## Deferred (documented non-goals of this cut) - **Background collection** (start tool → run id → completion notice → collect), designed alongside bash/subagent background unification. @@ -65,8 +71,8 @@ An output schema makes a schema-valid committed capture mandatory for successful - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. -- **Provider JSON mode (`response_format: {type: json_object}`) instead of the forced capture tool**: the official API guarantees valid JSON, not schema-conforming JSON (no `json_schema` type; the docs' own guidance is to validate client-side, with the schema riding in the prompt), so both walkers survive untouched and only the capture-tool mechanics could go — at the cost of tools during a structured child's run (whether `response_format` composes with tool calling is undocumented), the in-turn validation retry (`ToolArgsError` keeps recovery inside the turn; a JSON-mode empty body — a documented failure mode — ends the turn, and the only recovery is the re-prompt loop this design rejects), and a new per-adapter `LlmCallConfig` surface. The accepted upgrade path is strict TOOL schemas (provider-side constrained decoding on tool parameters) when available: the same forced tool and subset gate, with the gate narrowed to the provider's strict subset. +- **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design. ## Consequences -The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and `outputSchema` yields an authoritative structured child result across native and Code Mode presentation. The cost, bounded by the trust premise, is a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still not a security boundary — scripts share the model's trust level, and actual sandboxing requires an isolated-vm/separate-process engine behind the seam. The fatal-vs-null strictness divergence from CC means a CC-authored script that relies on option typos dissolving to `null` behaves differently, preserving the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. +Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. 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 9c2a506d71..521ddd55e7 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -49,44 +49,45 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request 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 request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. 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. `request()` also 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. +After validation and an `approval/asked` append, `request()` resolves to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. The service borrows the readonly request, runs the answerer waterfall, races cancellation, and normalizes thrown or invalid answers to `unavailable`. It then appends the matching `approval/decided`, paired by `ApprovalRequestId`. -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. +Both audit events must be inside an open turn; acceptance or a pre-commit append failure rejects the request. Post-commit observers are contained by the session. `allowed-once` grants only the requested action, and the service retains no grant state. -`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. +Answerers are `approval/request` waterfall listeners. A listener returns an outcome for an agent it owns and calls `next()` otherwise. With no answerer, the default is `unavailable`; unloading a UI therefore fails closed without leaving a channel. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and uses `prepend` only for decide-or-delegate gates. + +`ApprovalRequest` carries the agent, tool name, optional `callId`, reason, and signal. The agent routes both the prompt and audit events. The request uses `dsh-llm`'s `CallId` without importing `dsh-tools`, avoiding a package cycle. Tool arguments are omitted because UI answerers attach to the already-rendered call. #### Ask routing in dsh-tools -`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to. +`ToolRegistry.execute()` sends `ask` through the approval seam before the deny path. Only `allowed-once` proceeds; rejection, cancellation, and an unavailable channel produce distinct model-visible reasons. The registry looks up the optional service per call, so an absent or unloaded service fails closed without gating the registry fiber. Agent-less execution also fails closed because it cannot be routed or audited. #### The per-session policy tier -The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. +The seam owns the session policy `'ask' | 'never'`, following the switching contract in the [sandbox RFC](2026-07-06-sandbox.md). The effective session or config policy is applied before answerers: `'never'` rejects inside `request()`, while `'ask'` dispatches and falls through to `unavailable` when unanswered. The prompt states only deterministic `'never'`; the narrator reports switches, and every request still receives its audit pair. #### The ACP answerer -The bridge registers the first real answerer: it resolves the owning session through its existing `WeakMap` reverse map, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. +The ACP bridge finds the owning session, sends `session/request_permission` for the `callId`, and maps one-shot allow, reject, and cancel responses to the seam vocabulary. Unknown selections never grant. Foreign agents and requests without a `callId` delegate via `next()`; RPC failure becomes `unavailable`. The bridge answers requests but does not decide which calls require approval. The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. +`approval/asked` and `approval/decided` are durable log-only events. The model sees only the asker's logged `tool/result`. Every accepted request appends one matching decision, including cancellation and contained answerer failures. #### Entities and dependencies -One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (event-map merge + append), `dsh-agent` (the `Agent` type), `dsh-llm` (`CallId`, via `dsh-brand`). `dsh-tools` and `dsh-acp` each peer on it; the escalation phase's asker lives in `dsh-tool-bash` (see [the sandbox RFC](2026-07-06-sandbox.md) § Escalation), so the sandbox family keeps its ZERO-edge relation (the executor contributes the per-call override mechanism, and transport seams never ask humans questions). The seam is one package, not the capability-seam three: the service body (dispatch + audit) has no replaceable implementation — the replaceable part is the answerer listeners, and those live with their owners (the bridge; future terminal UIs; test scripts). `@cordisjs/plugin-capability` stays orthogonal (a static grant registry answers "is this already authorized", not "ask the user now"), and `subagent-acp`'s child-side `permission` auto-answer is untouched — routing a child's approvals to the parent session is deferred (§ Deferred). +`dsh-user-approval` owns the fixed dispatch-and-audit mechanism; `dsh-tools` asks and `dsh-acp` answers. Replaceable answerers remain listeners in their channel-owning plugins, so a three-package capability split would add an empty implementation layer. Sandbox executors remain transport-only, and static capability grants remain separate from interactive approval. ### Testing -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. - -Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). +- **Unit/integration:** cover first-wins delegation, fail-closed defaults, malformed and throwing answerers, cancellation races and late-answer discard, audit pairing despite observer failures, unbypassable `'never'`, distinct tool-denial reasons, and ACP per-session routing/outcome mapping. +- **Snapshot:** script permission answers through both sandbox escalation branches and pin the `'never'` prompt plus policy-switch notice. Hook-produced asks without a composed answerer remain covered as fail-closed denial. ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. +- **A recorded hook-produced ask with a composed answerer** — escalation records the human-prompt wire, while the current hook fixture pins the no-service denial; their combined producer/answerer path remains unit-covered. - **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered @@ -100,14 +101,10 @@ Snapshot tier: the harness accepts scripted permission answers (`permissionAnswe ## Consequences -The implemented contract is pinned by the suites in Testing: - -- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. -- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). -- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. -- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. -- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. +- Only `allowed-once` dispatches an asked-about action; absent, rejected, cancelled, or failed answer paths deny. +- Session ownership routes prompts, policy, and audit events without crossing editor sessions. +- Accepted requests append one durable audit pair; the model sees only the resulting tool result. +- A deployment without the service emits no approval prompt or audit events and denies every `ask` at the tool boundary. Costs and accepted limits: @@ -117,8 +114,6 @@ Costs and accepted limits: ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **What happens in a deployment with no answerer at all (headless, CI)?** Every ask falls through the empty waterfall to `unavailable` and the tool call denies with the "no approval channel is available" reason. Fail-closed is the zero-listener default, not a configuration. - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 39345c2b53..e8ef337206 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue. +Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy. ## Decision @@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w - The list must contain the rest entry exactly once and no duplicate names. - When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration. -The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change. +`assemble()` canonicalizes provider tools before the `system-prompt/assemble` waterfall, removing registration-order variance at its source. The waterfall starts from this deterministic list; unchanged order then flows into the request header, frozen request, and reconstruction checks without loop-specific ordering logic. Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). @@ -38,7 +38,6 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - Every registry-built assembly starts with a deterministic tool order on every host; absent an expert listener that deliberately changes it, every `request/header` event and model request inherits that order. The CI-vs-local registration-order flip is structurally gone, and the default is lexicographic. - The initial `PromptAssembly.tools` is canonical, so waterfall listeners start from the model-facing order; provider registration order is observable nowhere before that cooperative seam. -- The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design. - A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve. - The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched. - A misspelled or unloaded tool name in `toolOrder` fails the turn at prompt assembly, not the boot: the loop assembles inside the turn (after `turn/start`, before `step/start`), so the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason carrying the message, `agent/error` mirrors it, no step opens, no `request/header` is logged, no request reaches the adapter, and the agent returns to idle. Every turn fails identically until the config is fixed; the process itself stays up (matching the repo rule that explicit config references must not be silently ignored — the enforcement point is the assembly because no earlier universal moment exists). @@ -46,4 +45,4 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: ## Testing -Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`. +System-prompt tests cover lexicographic default order, listed/rest placement, provider-order independence, shared names, invalid lists, unknown or reserved names, the canonical pre-waterfall list, and the rule that listener-added tools are not re-sorted. Loop tests pin identical logged and dispatched order across registration permutations, forwarding through agent-core and both apps, deep-frozen requests, and balanced turn failure with no step, header, or adapter call for an unknown configured name. Snapshot replay keeps the full canonical list only in the pinned `text-turn` header; other fixtures continue to use `{{tools}}`. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index fa10a783a2..28f2066ee9 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -38,30 +38,13 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. -What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: one `Permissions` config-option select per session (advertised when the `dsh-permission` preset layer is composed; each preset bundles a sandbox mode and an approval policy and writes through to both knob events — a knob state outside the table derives a switch-away-only `custom` current), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. - -The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier): - -``` -tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it -tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt", - "sandbox_permissions": "workspace-write", - "justification": "the user asked to write escalated.txt in the workspace"} - → the editor is prompted on this very call (session/request_permission through the approval seam); Allow once -tool/result "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only -``` - -Reject instead and nothing executes: the result is the verbatim `the user rejected escalating this command to "workspace-write"`, and the teaching makes that final — no re-ask. +Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed, ACP exposes one `Permissions` select whose presets write both knob events; unmatched knobs appear as switch-away-only `custom`. Only a switch to the deterministic `'never'` approval policy is stated in the prompt and narrated. ### Design detail -#### Grounding — verified against the code +#### Scope grounding -- Runtime OS subprocesses exist at exactly two sites: the `ctx.bash` seam's single spawn (`packages/bash/bash-local/src/run.ts`; hook commands flow through `ctx.bash`, so bash confinement covers them transitively) and `subagent-acp`'s child agents (`packages/subagent/subagent-acp/src/run.ts`) — the second consumer that makes a shared seam due rather than preemptive under the [capability seams RFC](../architecture/2026-06-13-capability-seams.md)'s "don't split preemptively" rule. -- Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change. -- `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet. -- `dsh-bash`'s request/spec split (`BashExecRequest` → `resolve()` → `BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge. -- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the acp example suite's `permission-switching` fixture. +OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision. #### The seam: `ctx.sandbox` @@ -75,33 +58,33 @@ 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. -The launcher lives in its own repository and reaches the harness as the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) (the per-platform-package pattern of `node-addon-require-builtin` and esbuild): an entry package — `dsh-sandbox-local`'s one runtime dependency — plus per-platform binary packages selected at install time by npm's `os`/`cpu` fields. The entry package owns the launcher's CLI contract end to end (`launcherPath()` resolution with a never-existing fallback, the functional `probe()`, `grantArgs()` flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, `landlockProfileArgs()` mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it. +The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. -Profile parity is honest rather than identical: under Landlock, `read-only` grants `--ro /` plus `--rw /dev/null` (the node, not `/dev` — the host's `/dev/shm` is a persistent shared tmpfs), and `workspace-write` grants the HOST `/tmp` where bwrap's is ephemeral; under Seatbelt, `read-only` likewise grants only the `/dev/null` literal, and `workspace-write` grants the host `/tmp` plus the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (`denialSignatures`: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured `enforcement: 'full' | 'partial'` fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report `full`. +Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. #### 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). #### Escalation: one approved wider retry after a denial -The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: SandboxMode`, an explicit per-call policy input; `BashExecSpec` carries it required-but-nullable (the `owner` pattern: a forgotten field is a visible `undefined`, and `resolve()` is the one explicit defaulting step); `BashExecutor` exposes the capability fact `get sandboxMode(): SandboxMode | undefined` — `undefined` in the base class, the configured mode in `SandboxBashExecutor` — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (`dsh-bash-local`) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have. +`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. `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`. +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; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`. #### Per-session modes: the session log as the store @@ -122,11 +105,11 @@ 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. When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options are the deployment's preset table, and its `currentValue` is `PermissionService.current()` over the session log plus composition defaults. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy and write through to both domain setters; a knob combination outside the table is reported as switch-away-only `custom`. `session/set_config_option` validates and switches through the permission service, then returns the complete refreshed state (the spec contract). -**Anchoring: turn-enclosure is the commit boundary.** The turn-enclosure contract makes a bare between-turns append invalid (the JSONL backend treats a post-`turn/end` tail as crash garbage; dev invariants throw). A switch while a turn is open appends immediately — openness read from the LOG (last boundary event is `turn/start`), not `agent.status`, which stays `running` between queued turns. An idle switch is held on the bridge's session record and anchored at the next turn's `agent/prompt-submit` — inside the turn, before anything in it assembles or executes, last write per knob, and OUTSIDE any `session/event` emit (appending from inside that feed reorders events for later-registered listeners — a bug the dev invariants caught live). Until anchored, the switch exists only in bridge memory: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth, so the editor UI self-corrects rather than lies. +**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Openness comes from log boundaries rather than `agent.status`, and setters do not append from inside a `session/event` listener because that would reorder later observers. Until anchoring, responses overlay the pending value. A crash discards it, and reload returns the durable fold. #### In-process tools @@ -136,10 +119,10 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine ### Testing -- Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. -- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (the acp-agent example's `escalation.e2e.ts`): the real default `cordis.yml` tree advertises the one permission option, honors switches end to end, and rejects out-of-vocabulary values. -- With-key e2e (`examples/acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded permission-switching arc as the pinned header of its class — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing one `workspace-write`→`danger-full-access` preset switch (the `permission/preset` event written through to both knobs), the approval prompt-section delta and its "changed by the user" notice; and both recorded escalation branches over scripted `permissionAnswers` (grant runs under the granted `danger-full-access`; rejection executes nothing and pins the fail-closed text). Snapshot mode starts the shared example tree at `danger-full-access` so established fixtures remain runner-independent; the switching and escalation inputs explicitly select `workspace-write` before exercising the policy path. Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the real-runner tiers above. +- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. +- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. +- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific. ## Deferred phases @@ -198,15 +181,13 @@ Costs and accepted limits: - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. -- **An idle switch lives in bridge memory until the next turn anchors it.** A crash in that window reverts it (reported honestly on `session/load`), and a session that never runs another turn never persists it — accepted, with the loop-owned idle commit turn named as future work if durability becomes a requirement. +- **An idle switch lives in bridge memory until the next prompt submission anchors it.** A crash in that window reverts it (reported on `session/load`), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required. - **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice. - **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all. - **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry. ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). @@ -214,7 +195,7 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an `execve` wrapper is mechanically meaningless; their `read-only` semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly. - **Does a granted escalation persist, or cover background tasks?** Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via `bash_output` is left open in § Escalation. -- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next turn's `agent/prompt-submit`, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode. +- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next `agent/prompt-submit` inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode. - **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution). - **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`. diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 6f81d12407..f0d458368a 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -22,7 +22,7 @@ Because composition runs before the boundary snapshot, a composing listener's se ## Testing -**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition, zero `request/header-delta`s), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session codec tests cover the `messagePrefix` fold/diff/apply arms (empty ≡ absent); dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on. +[Interception tests](../../../../packages/core/agent-loop/tests/interception.spec.ts) pin compose-once reuse with no header deltas, prepend order, empty-prefix omission, immutability, and composition before pre-step; [cancellation tests](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pin discard and recomposition. Session codec, invariant, and compaction tests cover header round trips, request reconstruction, and prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the [pinned-header scenario](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md) owns content and the default example remains prefix-free. No prefix-specific e2e is needed because the seam is deterministic and provider-independent; the with-key [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) covers its cache economics. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 9d0446dcad..324aa37256 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -10,7 +10,7 @@ The harness already has every seam the pi extension uses, and better ones: [the ## Decision -The guard is a loop-hygiene plugin, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The purpose is to break unproductive loops within a few wasted steps instead of letting them run to the turn's natural end — while leaving the decision (retry differently, gather more evidence, or finish) entirely with the model, so a legitimately repeated call is delayed by nothing and blocked by nothing. +The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecutive calls to the same tool with identical canonical arguments and injects advisory reminders at configured thresholds. It never delays, blocks, or rewrites a call; the model decides whether to retry differently or finish. The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. @@ -29,7 +29,7 @@ Two deliberate rules, both documented in [the package README](../../../../packag ### Reminder delivery -Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered context as `context/message`(s) after the step's results, which the session renders as the tagged synthetic-user envelope and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at `argumentsPreviewChars`, default 500 — a looping `write`-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. When the downstream decision already carries `additionalContext` (a hook bridge on the same call), the guard concatenates content under its own `source` — a `HookContext` holds one `MessageSource`, and `source.kind` is what framing depends on. +Reminders use `additionalContext` with the plugin source, preserving the original `tool/result`. The first threshold emits a short nudge; later thresholds include the tool, count, and a bounded argument preview while comparison still uses the full canonical string. Existing downstream context is concatenated under the guard's source because `HookContext` supports one source. ### Config @@ -47,7 +47,9 @@ Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-too ## Testing -**Unit** — the suite drives a real agent loop against a scripted mock adapter (no network) and covers, at per-file 100%: counting/reset semantics (identical, different-tracked, untracked-transparent, prompt-submit reset, disposal cleanup, per-agent isolation), canonicalization (deep key-order insensitivity), threshold escalation including the `thresholds[0]` gentle-text rule, denied-call counting, no-agent transparency, wildcard escaping, config fail-loud cases, and both fold-onto-downstream paths (block and accept-with-replacement). **Snapshot** — the `repeat-tool-guard` scenario in the acp-agent example suite scripts five identical `todo_write` calls and pins both reminder tiers (gentle at the third, detailed at the fifth) as `context/message`s in the ACP transcript and the session log; the guard is loaded in the example's live tree (`cordis.yml`), inert for every other scenario (none repeats a call three times). The scenario is authored keyless (like `error-finish`/`cancel`): deterministically forcing a live model to repeat one call three times is not a stable recording. **e2e** — none: the plugin is provider-independent and deterministic, and the seam contracts it relies on are e2e-covered by their owners. +- **Unit:** A real loop with a scripted adapter covers counting and reset rules, untracked transparency, disposal cleanup, per-agent isolation, canonical argument key order, escalation, denied calls, no-agent execution, wildcard escaping, invalid config, and downstream block or replacement decisions at per-file 100% coverage. +- **Snapshot:** The keyless `repeat-tool-guard` scenario makes five identical `todo_write` calls and pins the gentle third-call and detailed fifth-call reminders in both ACP output and the session log. The plugin is loaded in the live example but remains inert in other scenarios. +- **E2e:** None; the plugin is deterministic and provider-independent, and its seam contracts are covered by their owners. ## Alternatives considered @@ -64,7 +66,6 @@ Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-too - The reminder is advisory by design: idempotent polling patterns that repeat identical calls on purpose still receive nudges past the thresholds, and the pressure valves are config (`thresholds`, `exclude`) plus reminder text that explicitly allows finishing when enough evidence has been gathered. Each trigger costs reminder tokens on the next request; thresholds bound the frequency. - Chain state is in-memory only: a session resumed from persistence starts with a fresh chain, so a loop spanning a resume draws its reminders later than a live one — accepted, the guard is a heuristic nudge, not a logged invariant, and persisting counter state would buy little for real complexity. - When multiple post-execute producers attach context on one call, the fold concatenates under the guard's `source`; ordering between plugins follows listener registration order. The seam cannot represent mixed provenance — a limit inherited from `HookContext`, not owned by this plugin. -- Implementing the snapshot tier surfaced a hidden assumption in the suite kit: the fixture guard equated "authored model scenario" with "override-driven". The `Scenario` table now carries an explicit `overridden` flag, and the sidecar's presence is checked BOTH ways against it (an unregistered stray sidecar would silently replace the derived script) — the suite kit is stricter than it was before this plugin existed. ## Deferred 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..7ea5f4390e 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 @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again. -The trust stance, stated once and threaded through the rest: the `node:vm` sandbox isolates the global context only — it prevents accidental global pollution, not malice — and the `ctx` a mounted plugin's `apply` receives is a whitelist façade that narrows the *surface* (framework internals withheld) but not the *privilege* of what it exposes. The verbs the façade does expose reach the real runtime: a mounted tool can shell out through `ctx.bash`, read the filesystem through `ctx.fs`, reach the network through `ctx.web`. Neither the sandbox nor the façade is a security boundary; handing the model this power is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's `cordis.yml`, never a product default. +The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a mount can call `ctx.bash` to run commands with the host executor's privileges and can reach the real filesystem and web services. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. ### The three tools @@ -26,17 +26,17 @@ The trust stance, stated once and threaded through the rest: the `node:vm` sandb ### Sandbox semantics -Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of an async function under a per-mount filename (`cordis-mount-.js`). The vm gives the code a fresh realm: writes to `globalThis` stay inside the sandbox, and no Node API is handed in — capability access is *steered* toward the cordis services (`ctx.fs` for files, `ctx.web` for HTTP, `ctx.bash` for processes, the `ctx.timer` helpers for timing) rather than Node built-ins, so a well-behaved mount stays inspectable through `cordis_inspect` and disposable with its fiber. This is steering, not containment: consistent with the trust stance above, the small global surface keeps *honest* code on the cordis services but is not a security boundary — the host-realm helpers it exposes (`harness`, `console`, `btoa`) are reachable functions, so mount code that goes looking (through such a helper's `.constructor`, say) can still reach the host realm and Node itself, which is accepted because the `ctx` a mount ultimately receives is fully privileged anyway. The `vmTimeoutMs` config bounds only the synchronous portion of evaluation; an async body escapes the bound (also acceptable under the trust stance). +Mount code runs as an async-function body in a fresh vm realm. Its documented surface steers file, network, process, and timer access through Cordis services so mounts remain inspectable and disposable. Host-realm helpers still make Node escape possible, consistent with the trusted posture. `vmTimeoutMs` bounds only synchronous evaluation. 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. +The boundary normalizes unambiguous JSON-Schema forms into `SchemaSpec`, including object wrappers, `integer`, and optional fields. Invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. ### The dynamic group and mount lifecycle -Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself a child of the `tool-cordis` plugin's fiber. The group exists so the mounts form one subtree: they are disposed as a unit, and disposing `tool-cordis` (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is `await()`ed, and a startup error (a throwing `apply`, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied `inject` — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so `cordis_unmount` is nothing but an awaited `fiber.dispose()`. +All dynamic mounts are children of one `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles reload and unload. Mounting awaits settlement; startup failure disposes the fiber before returning an error. A settled pending mount remains visible with its missing injections. `cordis_unmount` awaits the mount fiber's disposal. ### Cross-mount composition via provide/inject @@ -44,7 +44,7 @@ Mounts relate to each other through ordinary cordis service semantics, with thei ### The generated API catalog -`cordis_inspect what:"api"` and `what:"events"` answer from a machine-readable catalog generated at build time, never a hand-maintained table that would drift from the JSDoc it paraphrases. [`scripts/gen-cordis-api.ts`](../../../../scripts/gen-cordis-api.ts) reuses `collectServices` / `collectEvents` from [`scripts/gen-cordis-catalog.ts`](../../../../scripts/gen-cordis-catalog.ts) — the same AST walk that generates [the cordis service catalog](../../../cordis-catalog/services.md) and [events catalog](../../../cordis-catalog/events.md) — and emits `packages/cordis/tool-cordis/src/api-catalog.ts`, a committed, banner-commented data module. The artifact carries, per service, its key + one-line summary + raw method signatures; per event, name + `@mode` + signature + summary; the comment-stripped declarations of every exported type the service signatures reference (transitive closure — so a consumer sees that a bash run's `stdout` is `{ text, truncated }`, not a string); plus the curated inherited `ctx` surface shared with the cordis catalog generator. A type name declared in more than one package (each plugin's `Config`) is dropped as ambiguous, and an oversized declaration is truncated with a marker. +`cordis_inspect` serves API and event data from a generated catalog rather than a duplicated table. The generator reuses the Cordis catalog AST scan and emits service summaries, signatures, event modes, referenced type declarations, and the inherited context surface. Ambiguous type names are omitted and oversized declarations are marked as truncated. Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc edit that changes a public signature cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: live catalogued services render summary + signatures, live services without a catalog entry (mount-provided ones) render name + owning fiber, catalogued services with no live provider are listed tersely, and the referenced type shapes follow. @@ -78,7 +78,3 @@ The correctness investment therefore goes where it pays for every capability at ## Consequences The toolset is a deliberate opt-in with a fully-privileged `ctx`, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. `tools/pre-execute`) that returns without calling `next()` vetoes the chain, so a mounted listener can lobotomize the agent's own tool dispatch ([waterfall semantics](../../../cordis-primer.md#cordis-waterfall-semantics)); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; `vmTimeoutMs` bounds synchronous evaluation only; and mounts do not survive session resume. - -The instructive boundary errors were not guessed — they were written against live self-design sessions in which a real model was asked to build itself coding tools. Those sessions surfaced the failure modes now mitigated: the model closed a returned plugin object with `});` and got only a bare `Unexpected token ')'` it retried blind; it hit a false-positive "this is TypeScript" hint because a description string contained the word "as"; it guessed a bash run's `stdout` was a string and burned six steps building throwaway debug tools to discover it is `{ text, truncated }`; and it wrote tool schemas in the JSON-Schema dialect (`type: 'integer'`, `required: false`, then the full wrapper) three rejections in a row — the rejection text itself pushing it from a nearly-correct DSL attempt back to raw JSON Schema. The fixes — source-line-plus-caret parse errors, line-scoped TypeScript detection, the type-shape closure in the API catalog, the redirect traps, and schema-dialect normalization in place of rejection — cut later sessions from dozens of tool calls with repeated errors to a first-try success on every capability, including a model that hit a Node-`setTimeout` trap and self-corrected to `inject: ['timer']` in one step. - -Coverage is named per tier: package unit specs drive the three tools through a real `ToolRegistry` on a real fiber tree (the mount success/failure family, vm isolation, dual-realm `instanceof`, realm normalization against the real `isJsonValue`, the SchemaSpec and raw-registration rejections, the Node-API traps, the cross-mount provide/inject matrix, catalog-backed `api`/`events` rendering, config validation, presenters, quiescent unmount, and the HMR cascade), a `MockAdapter` loop test proves a tool mounted in one step is dispatchable in the next, and the example carries a keyless Loader smoke plus a with-key smoke that world-verifies a live model mounting a listener, building its own tool, and composing two mounts. No snapshot scenario is added: the toolset ships in no ACP-served app, so it changes no editor-facing transcript, and its presenters are unit-tested pure functions — adding it to the ACP example solely for a golden would rewrite the pinned request-header tool set of every recorded scenario. diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md index fd5f01590c..1e63cef940 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -12,7 +12,7 @@ Build output currently matters only for `pnpm run build` + publint (nothing publ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released): -- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not pnpm workspaces). +- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*/*']` (explicit globs keep bundling to vendored Cordis and the TypeScript package tree; `workspace: true` would also discover example manifests and non-bundled workspace members). - Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build RFC](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). - `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index 37678096ab..aaac6fbd3c 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -44,13 +44,11 @@ The durability requirement was specific: the doc should show the **literal** cur - **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. - **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. -## Process +## Verification lesson -The design was driven entirely by a one-question-at-a-time grilling that walked the scoping decision tree through concrete examples (`BashExecRequest`, `ToolSchema`, `ToolDefinition`, the schema DSL, the presentation types, the session/persistence split) before committing to the spine-vs-seam rule — the rule was the *output* of the examples, not an a-priori axiom. The implementation landed as four commits mirroring the structure of the work: the gate (`e97f94b`), the catalog (`7e33c7b`), the maintenance-guard updates (`53e01a0`), and a review-fix commit (`6da7a0f`). +The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and definitions, the schema DSL, presentation types, and the session/persistence split before adoption. -That last commit is why the process is worth recording: an independent Codex review (gpt-5.5:xhigh) found a real **scan-gap bug** — `verify-type-equiv` only scanned the docs the manifest named, so a type-equiv block added to an *unmanifested* doc was silently skipped, defeating the 1:1 guarantee in one direction. The fix scans every doc in the markdown scope and reports an unmanifested block as an orphan. The same review corrected a `SessionPersistence` surface-listing prose error (`has`/`delete`) and the `doc-sync` command summary. The bug is the point: a drift gate that silently skips part of its input is worse than no gate, and only an adversarial reader caught it. - -This decision shipped in #71 **without** an RFC at the time — the judgment was that the `ts type-equiv` convention was small enough to document in `development.md`. This RFC is the retroactive record: the spine-vs-seam scoping rule and the verbatim-match-over-assignability choice are exactly the kind of "why was it done this way?" decisions a future maintainer would otherwise re-litigate, and its sibling catalog ([generated cordis events + services](2026-06-20-generated-cordis-catalog.md)) does carry an RFC, so the pair should be documented symmetrically. +`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This RFC records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its RFC](2026-06-20-generated-cordis-catalog.md). ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index f0b175303c..eafce4feae 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -12,7 +12,7 @@ This is the wiring-axis complement to the [core-data-structures catalog](../../. Generate the catalog from source instead of hand-maintaining a table and verifying a subset. -`scripts/gen-cordis-catalog.ts` walks the `interface Events` and `interface Context` declarations (plus the service classes) with the TypeScript compiler API and emits two sibling pages: `docs/cordis-catalog/events.md` (events grouped by scope, each rendered as signature + mode badge + its source JSDoc, plus the dispatch-mode legend) and `docs/cordis-catalog/services.md` (each `ctx.` with its public method signatures + class JSDoc). The two axes are separate documents — a reader is either finding what to listen to or what to call, and each page scans and deep-links as its own reference instead of one long combined scroll. It mirrors the `gen-module-graph` pattern exactly: `--write` regenerates both, `--check` fails if either committed file is stale, output is deterministic (sorted), and the files are build artifacts that are never hand-edited. `verify-cordis-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. +`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes; services include public signatures. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 3e0b719ffd..7d79f73583 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog.md`, and a freshness gate so it cannot drift. +The repository had no single reference for the names, descriptions, and JSON Schemas actually exposed to the model. Source declarations are scattered and runtime-composed, while the existing Cordis and data-structure catalogs cover wiring and vocabulary rather than tools. ## Decision @@ -27,13 +27,13 @@ Booting has a cost the AST pass did not: there is no source declaration set to e ### A hand-maintained boot manifest is the irreducible policy -The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with the proposed [Discover package inventories instead of maintaining static lists](../../proposed/process/2026-06-20-discover-package-inventory.md). The tension is deliberate and resolved as follows: the *inventory* is discovered (the glob guard means no one maintains "the list of tool packages" — the filesystem is the source of truth, and drift fails the gate), but the *boot recipe* per package — which seams to plug (`bash-local` for `ctx.bash`, `subagent` + `subagent-mock` for `ctx.subagents`) and with what config (`{ provider: 'mock' }`) — is genuine policy that no layout fact encodes. Per that RFC's own "what we give up" ("stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud"), a recipe closure is the boring, explicit form; inferring seam wiring from injects would be the "too clever" path it warns against. So: discovered inventory, hand-written recipe, gate on completeness. +The filesystem discovers the tool-package inventory and the completeness guard rejects omissions. `TOOL_PACKAGES` still owns an explicit boot recipe for each package because required seam implementations and config are policy, not facts that can be inferred safely from layout or injection names. ### Scope Shipped product tool PACKAGES under `packages/*/tool-*`, each booted with its default config: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. -The unit is the PACKAGE, not the deployed tool instance. A package's registered tool name can be a load-time config — `tool-subagent`'s `toolName` — so the same package surfaces as `subagent` (spawn backend) AND `subagent_fork` (fork backend) in the shipped `coding-agent` / `acp-agent` configs, with an identical schema. The generator boots each package once at its default and records such shipped aliases in a per-package note, rather than enumerating every deployment permutation. Cataloguing at the package level keeps the source of truth the package (what a plugin author reads) and avoids leaking example-app `cordis.yml` config into a packages-scoped generator; the note keeps the doc honest about the names a reader will actually see the model receive. The design deliberately does not attempt to catalog "every configured tool instance across every leaf config" — that is a deployment inventory, a different (and unbounded) surface. +The catalog unit is a package, not every configured tool instance. Each package boots once with default config; load-time aliases such as `subagent_fork` are noted without enumerating every deployment permutation. A deployment inventory is a separate, unbounded surface. ### A plain `json` fence diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 9db59e1138..42ab306bb0 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE. +The generated Cordis catalog enforced event dispatch modes but not complete service and event contracts. Methods could lack descriptions, and parameters or returns could be undocumented on the cross-plugin API surface where IDE guidance matters most. The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run. diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 5d886fa4bd..3f88e43c1d 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 50 commits in two and a half weeks — each PR appending its own lesson, none displacing anything — until the same rule was stated two or three times inside one file (the pushed-branch rewrite ban ~600 words across two sections; the with-key e2e policy ~400 words across two), an incident already recorded in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) was retold inline at ~750 words, and the per-package one-liner map existed in five places. [architecture.md](../../../architecture.md) grew the same way: paragraph walls re-narrating RFCs it already links, plus implementation-status annotations that were stale the week after they were written. The writing rules that forbid this (document current state, never history) predate the drift and sat in the very file violating them — prose rules alone do not hold against accretion pressure. The repo's standing answer to an invariant of this kind is a mechanical check ([quality gates](2026-06-11-quality-gates.md), [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)). +Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale RFC summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy. ## Decision @@ -24,11 +24,3 @@ The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 5 - Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. - The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. - -## Deferred work - -The first audit cycle under the standard, in rough priority order (evidence gathered in the survey that motivated this RFC): - -- Package README trims where generated catalogs or JSDoc are restated or history is narrated: `packages/ui/acp`, `packages/core/tools`, `packages/bash/tool-bash`, `packages/core/session`, `packages/compact/compact-basic`, `packages/session-persistence/session-persistence`. -- `docs/core-data-structures/core.md`: drop the JSDoc walls from the `Agent`/`GenerateOptions` type-equiv pastes per that page's own stated rule. -- [Postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md): merge the overlapping Executive summary and Summary sections. 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..0de6255322 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,13 +4,13 @@ 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 Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools). -`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` — log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope. +`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders source JSDoc, payload type, derived surface badge, reference links, and source location. The doc-sync freshness check rejects a vocabulary change whose catalog was not regenerated. Specific choices: diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md index 696d46cb20..d5ce28066d 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some twenty-seven `Status:` line spellings once free-text rejection reasons are collapsed — bare enums, dated parentheticals duplicating what the filename and git already carry — plus three English files (and the zh counterpart of one of them) with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and nineteen implemented RFCs still carrying thirty occurrences of the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`, `Proposal`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires. +RFC paths encoded lifecycle and class, but file contents still mixed headings, status formats, ADR and proposal templates, and proposal-era sections in implemented records. Authors copied whichever neighbor they found, and lifecycle moves could skip the required rewrite because no gate enforced an in-file contract. ## Decision diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 2e98808426..6ebb477dea 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -22,7 +22,7 @@ The contract by declaration kind: Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): -- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where 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 (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter), and a concrete result above a void base return keeps its `@returns` duty (an unannotated override's inferred return is classified by the checker, so a faithful void override needs no boilerplate annotation). Heritage lookups and that one return classification are the walk's only TYPE CHECKER questions (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). +- **Heritage members.** Overrides inherit documentation from their base declaration. New public surface still requires docs: added parameters, a public override of a protected member, or a concrete return over a void base. Heritage lookup and inferred return classification are the gate's only type-checker work; other checks use the AST. - **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics. - **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story. diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md index 7693ff074c..999ebd8503 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md @@ -4,11 +4,11 @@ Status: implemented ## Problem -The config surface — the exact set of fields a `cordis.yml` entry's `config:` block can set for each plugin, with types, defaults, and semantics — had no reference page. A deployment author assembling a config tree had to open every plugin's source (or trust its README) to learn what is settable. The per-package README `## Config` sections cover parts of it by hand, in formats that diverged package-by-package (a key/default table here, an annotated YAML snippet there) and with no gate tying them to source. Nothing enumerated which packages are loadable at all — plugin vs abstract seam vs plain library — and nothing verified that the runtime schemastery schema and the documented `Config` interface agree, so a schema-validated field could exist with no documentation anywhere. +The repository had no source-backed reference for plugin configuration. Package READMEs documented fields inconsistently, did not enumerate which packages are loadable, and did not verify that runtime schemas agree with declared config types. ## Decision -Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. The paste is the plugin's full declared config type: a field the runtime schema deliberately excludes is a runtime-only seam, marked as such by its own JSDoc, not a `cordis.yml`-settable knob. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited. +`scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md) from each plugin's declared config type and JSDoc, with injection requirements, referenced-type links, and a source pointer. Package-local types are included transitively; workspace and external types are linked or named. Deterministic `--write` and `--check` modes make the committed page a generated artifact. Pure AST generation is correct here for the same reason it is for the events/services catalog and NOT for the tool catalog: a config type is a static declaration and every schemastery schema in the repo is a static `z.object`/`z.intersect` literal, so the source is the whole truth — nothing about the config surface is runtime-composed. @@ -17,7 +17,7 @@ Specific choices: - **The config type is the second-parameter type.** What the catalog documents is the declared type of `apply(ctx, config)` / the service constructor's `(ctx, config)` — the value cordis actually passes — not a `Config` export located by naming convention. This is what makes the walk total: it works for interfaces named `AcpConfig` or `BasicCompactConfig`, for types declared in a sibling file, and for plugins with no validating schema at all. - **Classification is total.** Every `packages//` entry resolves, mirroring the Loader's `unwrapExports` (`exports.default ?? exports`), to a configurable plugin, a config-free plugin, an abstract seam class, or a library — each rendered in its own section — and an unclassifiable entry hard-errors. A new package cannot be silently undocumented. - **Per-field JSDoc is enforced.** Every property of a pasted declaration (nested type literals included) needs non-empty JSDoc prose, or generation fails. The paste IS the documentation, so this is the same forcing function the events catalog applies via `@mode`: thin source docs fail the gate rather than yielding a thin catalog. -- **The schema is cross-checked, one-directionally, nested keys included.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys and their nested object/array compositions as key paths (`agents[].id`), chained refinements, and `z.intersect` composition across workspace 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 included), intersections, unions, utility wrappers, and indexed access. So the paste cannot hide a loader-accepted field, top-level or nested. The check is presence-only and fails loud only on a definite miss: a path crossing a type the walk cannot enumerate (an external package's type) is skipped rather than mis-reported, and dynamic-key shapes (`z.dict`) or union alternatives contribute no nested paths. The reverse direction is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`). +- **Schema keys are checked against the declared type.** The generator resolves nested object and array paths through local and workspace types. Definite missing paths fail; external or dynamic shapes that cannot be enumerated are skipped. The check is intentionally one-way because declared types may contain runtime-only fields excluded from loader config. - **A dedicated fence.** Pasted declarations use a ` ```ts config-catalog ` info string that `doc-typecheck` skips (a lone declaration referencing imported types is not standalone-compilable), excluded from the opt-out ratio — the same treatment the `cordis-catalog` and `persistence-catalog` fences get. - **A single file at `docs/config-catalog.md`**, not a one-file directory: the page serves one audience (the `cordis.yml` author) with one axis, unlike `cordis-catalog/`, which holds two sibling pages. diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index 061d534b9b..be31a439c1 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -10,15 +10,15 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and ## Decision -[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The compatibility matrix has Node 22.19, 24, and 26 jobs; each installs once and runs `pnpm run check:node-compat`. +[CI](../../../../.github/workflows/ci.yml) groups keyless checks into broad primary-runtime lanes plus a compatibility matrix. The workflow file owns the current lane and runtime inventory. -Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers. Every compatibility job runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke, which starts a real unbuilt worker and therefore catches Node-version-specific loader/runtime failures that typechecking cannot. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log. +Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), which schedules independent gates with bounded concurrency and prints an attributable result block for each one. Artifact consumers depend on one build within their lane, while compatibility jobs combine typechecking with a real unbuilt worker launch to cover runtime-specific loader behavior. Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane. Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report. -Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=14` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split. +Both workflows cache the pnpm store. The real-API workflow uses the shared bounded Vitest file pool rather than a separate job per test group. ## Alternatives considered diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md index 41ba156c30..6cd8851149 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md @@ -4,25 +4,24 @@ Status: implemented ## Problem -The [documentation standard](../../../AGENTS.md) assigns limitations to the package-README tier ("the per-package contract: config, semantics, limitations, extension points"). Without a required shared shape, variant headings and omissions make "this package has no known limitations" indistinguishable from "nobody wrote them down", and no single grep can enumerate the repo's known gaps. +The [documentation standard](../../../AGENTS.md) assigns limitations to package READMEs. Without a shared shape, an omitted section cannot distinguish an audited absence from forgotten documentation, and variant headings prevent a repository-wide search. ## Decision -Every package manifest under `packages///package.json` has a sibling README carrying a canonical `## Known Limitations and Deferred Work` section: a condensed bullet list of consumer-visible gaps (unimplemented features, platform caveats, deliberate MVP cuts) and consciously postponed work (TODO markers, RFC deferrals still open). A `doc-sync` gate, `verify-package-readme-limitations` ([scripts/verify-package-readme-limitations.ts](../../../../scripts/verify-package-readme-limitations.ts)), derives the package set from those manifests, rejects a missing README, and enforces the shape per README: exactly one limitations-like heading, byte-equal to the canonical h2, with at least one top-level bullet. Near-miss headings at any level ("Limitations", "Deferred", "What is NOT here", "Non-goals", …) fail the gate, so variant sections cannot creep back beside — or instead of — the canonical one. +Every package manifest under `packages///package.json` has a sibling README with the canonical `## Known Limitations and Deferred Work` section. Its bullets record durable consumer gaps and non-obvious maintainer constraints owned by that package; ordinary cleanup remains in its source TODO or owning RFC. The [`verify-package-readme-limitations` gate](../../../../scripts/verify-package-readme-limitations.ts) derives the package set from manifests, rejects missing READMEs, and requires exactly one canonical h2 with at least one top-level bullet. Near-miss headings such as “Limitations,” “Deferred,” “What is NOT here,” or “Non-goals” fail. -A package with genuinely nothing to declare is whitelisted (`NO_LIMITATIONS` in the script) and must NOT carry the section. The inverted check keeps the whitelist honest in both directions: an empty or boilerplate section cannot satisfy the gate, and giving a whitelisted package real limitations forces the whitelist edit in the same change. Whitelist entries are validated against the scanned package set, so a package rename or removal fails loud instead of silently un-gating a README. +A package with nothing to declare is listed in `NO_LIMITATIONS` and omits the section. Adding a limitation requires removing the entry; renames and removals fail because every entry must name a scanned package. -The gate checks presence, shape, and the whitelist; the bullets' truthfulness and specificity are governed by review under the documentation standard, like the rest of the README tier. The standing rule lives in [packages/AGENTS.md](../../../../packages/AGENTS.md). +The gate checks presence, shape, and the allowlist. Review under the documentation and [prose](../../../../.agents/skills/dsh-prose-standard/SKILL.md) standards owns coverage and accuracy. The standing rule lives in [packages/AGENTS.md](../../../../packages/AGENTS.md). ## Alternatives considered -- **Free-form headings, gate only that "something limitations-like" exists** — preserves variant headings, stays un-greppable, and needs the same near-miss heuristics anyway without buying uniformity. -- **Require the section in ALL READMEs, allowing an empty body or "None."** — boilerplate "None" rots silently as a package gains real limitations; the whitelist inversion turns "nothing declared" into an explicit, lintable claim that review can challenge. -- **A word-count ceiling on the section** — limitation lists are legitimately variable in length; package READMEs are deliberately unbudgeted (per the [budget policy](../../../AGENTS.md)) and review governs their prose. +- **Free-form headings** — cannot be searched uniformly and still need near-miss detection. +- **Require an empty section or “None.”** — boilerplate can remain after a package gains a limitation; an allowlist makes absence explicit and reviewable. +- **Impose a word ceiling** — legitimate limitation counts vary, so review governs this unbudgeted README tier. ## Consequences -- A new package cannot ship without either declaring its gaps or explicitly claiming it has none; a missing, drifted, or empty section fails `doc-sync` locally (pre-push) and in CI (`package-readme-limitations` in the run-gates doc-sync leaf set). -- Every package README answers the limitations question through the canonical heading or an explicit no-limitations allowlist entry. -- One more fast tsx script in the `doc-sync` chain; no new dependency (plain `node:fs` glob + line scan). -- The canonical heading is enforced verbatim, so renaming it later is a mechanical one-script-plus-all-READMEs change guarded by the same gate. +- New packages declare qualifying limitations or explicitly join the allowlist; missing, drifted, and empty sections fail `doc-sync` locally and in CI. +- The gate adds one dependency-free TypeScript script to `doc-sync`. +- Renaming the enforced heading requires changing the script and every package README together. diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index f6c098ef5d..0f9b0b02a0 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -8,11 +8,13 @@ A package README can explain APIs and runtime mechanics without answering the qu ## Decision -Every workspace package README with a model-facing or model-adjacent contract ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. An audited generic package whose public contract is model-agnostic omits Model Experience through `NO_MODEL_EXPERIENCE_SECTION`, independently of whether it has a limitations section. Packages with direct, multi-surface, conditional, capped, or lifetime effects use one H3 block per context surface. Each block says what the relevant model literally receives and when under `**What the model sees**:`, then classifies the token effect under `**Token effect**:`; the structured section grounds at least one surface with inline code, a nested `markdown` block, or an anchored catalog link. Every stable system-prompt paragraph, including a one-liner, follows those fields inside the owning H3 as a titled H4 plus `markdown` fence; the H3 title contains `system prompt`. Other short stable source literals remain inline with named placeholders only for interpolated values; other long non-generated literals use the same nested H4 form. Model Experience subsections do not link to each other because physical nesting owns the literal. Tool-schema surfaces use `schema` in their H3 and link the relevant anchored package section of the generated [tool schema catalog](../../../tool-catalog.md) rather than copying default descriptions or JSON Schema, then state only configuration or composition deltas absent from that catalog. A runtime-only definition outside the catalog's stated scope links that scope and explains the exception before reproducing its stable text. Summaries are reserved for data-dependent payloads and provider-owned text. The default subject is the conversation model; a package that invokes an auxiliary model, such as a summarizer or search provider, names that request separately. Agent-scoped visibility is stated where it changes which agent receives a contribution. Prompt text and tool schemas are described separately whenever configuration or scoping can hide one without the other. +Every workspace package README with a model-facing or model-adjacent contract ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. An audited model-agnostic generic package omits the section through `NO_MODEL_EXPERIENCE_SECTION`. -Every non-omitted package participates. A package with no model-context effect, or one simple effect rendered entirely by another package, uses the verifier's audited sentence allowlist instead of expanding one fact into a structured block. It carries exactly one sentence beginning `None, as ` or `Indirectly, through `. Pure transport and keyless test-support packages use the explained none form when they create no model-bound content. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses the indirect form even when it caps or filters data before returning it; wiring bundles do the same when every model effect belongs to named children. The indirect form names that consumer only to locate this package's contribution and does not restate the consumer's implementation. Structured blocks likewise document only package-owned inputs, transformations, and deltas. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks even when they add zero direct prompt tokens. +Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each names what the relevant model receives and when, then classifies the token effect. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a nested H4 plus `markdown` fence, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. -`verify-package-readme-model-experience` discovers packages from `packages/*/*/package.json`, requires one sibling README, and validates one of three package classifications. A no-section package carries no Model Experience heading, an explanatory short-form package carries exactly one sentence with its assigned prefix, and every other package carries at least one H3 context surface with the two exact, non-empty fields and one blank line between each element plus at least one inline literal, nested block, or catalog link across the section. For packages with the section, the verifier also enforces the canonical final-section order. Optional verbatim literals follow those fields inside that surface, each as an H4 title paired to one non-empty `markdown` fence. Local subsection links are rejected, every system-prompt surface requires at least one nested block, and every tool-schema surface links an existing H2 section in the generated tool catalog. The check runs in `doc-sync` and the parallel gate runner. It owns package classification, structural presence, concrete-literal evidence, nested-block shape, catalog-link shape, and order; implementation review owns coverage, link relevance, and the truth of the prose. +A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited one-sentence form: `None, as ` or `Indirectly, through `. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sentences locate the contribution without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. + +`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, required fields, concrete literal evidence, nested verbatim blocks, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index 0a80ec5a41..a93e3d3196 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -37,6 +37,6 @@ Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposal - **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. -**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`). The session log uses the **pinned-`0` "unstable / pre-release"** format stance (one of the two stances AGENTS.md § pre-release sanctions): `SESSION_FORMAT_VERSION` stays `0` and absorbs this and every other pre-release shape change without a monotonic bump — bumping on each tweak would dress up an unstable format as a sequence of stable boundaries that mean nothing yet. The constant is centralized in `dsh-session` and read by both write sites and the coordinator's load-time check, which rejects any non-`0` log (no migration — there is no persisted user data to preserve; a real monotonic policy begins at the first tagged release). `turn/end.reason.error.step` is required for newly-written logs. +**Format version.** This changes persisted events, but the pre-release session format remains pinned at `0` and rejects any other version without migration. `dsh-session` owns the constant used by writers and load validation. Monotonic format versions begin at the first release. Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 585c611262..2b10bf36db 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -18,7 +18,7 @@ This is the [drop-mutable-session-summary](../../implemented/simplification/2026 ## Decision -`stream()` is the only public LLM call surface. Removed with their JSDoc and doc references: `LlmService.streamBlocks()`; `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult`; `BlockAssembler.flushReady()`/`flushRemaining()` and the `flushed` cursor field; and `BlockAssembler.result()`, which only served the deleted `generate()` path. Adapter tests drive `ctx.llm.stream()` through a small helper that pushes chunks into `BlockAssembler` and returns the assembled message, usage, and finish reason — keeping the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact without a public method whose only callers are tests. The assembler invariants that apply to `push()` / `blocks()` / `message()` keep their tests; the flush-API pins went with the API. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) is `stream()` only, the event taxonomy carries no `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without the removed convenience methods. +`stream()` is the sole public LLM call surface. Remove `streamBlocks`, `generate`, its event/result types, and assembler helpers used only by that path. Adapter tests assemble the public stream through a local helper, while `BlockAssembler` retains only the operations with production consumers. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 765cadd573..ce16ff2ee2 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,7 +2,7 @@ Status: implemented -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. +> **Implementation note:** Only `SessionPersistence.has()` and `.delete()` were removed. `BashExecutor.get()` and `.list()` remain because removing their one-line lookup surface required substantially more completion-tracking machinery in consumers. Their id branding is covered by the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md). ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index b97d0abcee..7ed7d10211 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -2,7 +2,7 @@ Status: implemented -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [the defensive patterns](../../../defensive-patterns.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped. +> **Implementation note:** Only `abort()` was removed. `whenIdle()` remains because it is the public quiescence signal and safely handles waiter settlement and replacement-turn races; consumers should not reconstruct that behavior from status transitions. ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index dbef22f3a3..e2a1165846 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -2,18 +2,9 @@ Status: implemented - - ## Problem -The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. +The loop exposed durable turn and step boundaries through both the replayable `SessionEvent` log and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. ACP and persistence already used the log; the stdio UI was the only remaining mirror consumer and already rendered tool calls and results from `session/event`. This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. @@ -21,24 +12,18 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. +Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that needs an agent label maintains a session-to-agent map from `agent/created` and `agent/disposed`, because the durable `turn/start` carries the turn number but not the agent id. -The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. +The step mirrors had no consumers and were removed first by the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md). That decision retained the turn mirrors for the stdio UI; this RFC removes them after migrating that test REPL to `session/event` and the id map. ## Scope: what is and isn't removed -Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. - -RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: - -- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). -- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). -- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. +This decision covers only durable turn and step boundaries. `agent/steering` mirrored a control record and `agent/stream-chunk` mirrored the token stream, so each was handled separately: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors; queued input may be cancelled before any durable event exists. ## Alternatives considered -- **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). -- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` + the id map instead. +- **Remove `agent/steering` in the same change** — rejected because it was a control-record mirror rather than a boundary mirror. +- **Keep turn mirrors for the stdio UI** — rejected because the UI can render `session/event` and recover the agent label from its id map. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md index 0e999c629f..73645e8fda 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -8,19 +8,19 @@ Status: implemented ## Decision -Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. +Remove `ImageBlock`, its map entry, and image-specific branches from adapters, ACP rendering, and compaction. Update the owning vocabulary docs and generated references in the same change. Unknown extension blocks still exercise default branches, and ACP continues to reject inbound image prompt content independently of the harness vocabulary. ## Alternatives considered ### Why not keep it? -This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. +`ContentBlockMap` can reintroduce images when adapters, ACP, and compaction all support them. Keeping a core type whose only implementation is rejection would advertise an unusable surface; absence gives producers an immediate compile-time failure instead. The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. ## Verification -No `ImageBlock` / harness `type: 'image'` block is constructed anywhere outside RFC records; the codec's inbound ACP-image rejection keeps its tests; and the adapter/codec/compaction switches handle the case through their unknown-block default arms, pinned by the plugin-added-block tests. +No harness `ImageBlock` is constructed outside RFC records. ACP's independent inbound-image rejection remains tested, while adapter, codec, and compaction default branches are covered with plugin-defined block types. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index b6fbded6ac..35868be2fd 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -15,7 +15,7 @@ This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/ ## Decision -The event declaration, both emits, and the rollback-before-emit ordering are deleted (the plain `ctx.effect` disposer carries HMR cleanup). `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` are deleted — the provider-private `status()` stays, since it feeds execution-time selection. The listener-throw rollback test that existed solely for the removed event is gone, and the emission assertions and every status-based assertion are rewritten onto the behavior a real caller observes: a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets. The cordis catalog is regenerated; `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md) describe the shipped contract; the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) are amended per [implemented/AGENTS.md](../AGENTS.md). +Remove the registry-change event, aggregated status methods and type, and their dedicated tests. Provider-private status remains for execution-time selection. Caller-facing coverage now asserts successful execution or structured selection errors, and the owning web docs describe that on-call contract. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 38ec9ee4fa..2c7626619b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the app's export SHAPE is pinned by the stdio-agent unit suite's explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam. Per-file tests cover EOF, rendering, disposal, and piped-versus-TTY behavior without replacing process globals. It retains the named Cordis plugin export shape consumed by the app; an `unwrapExports` assertion and keyless Loader smokes guard both the package and composed entry paths. The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md index 4c76d7f044..a0a383b255 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -6,7 +6,7 @@ Status: implemented `agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. -Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC makes. The [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) kept it as a live control signal rather than a boundary; the [stream-chunk removal](2026-07-02-remove-stream-chunk-mirror.md) retained it on the reading that it had no durable twin. The second rationale did not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fired at the exact moment its durable twin landed, carrying nothing the log does not. +`agent/steering` duplicated the immediately preceding durable `steering/message` with the same payload. `agent/queued` remains the live-only signal because it fires before persistence and covers work that may be cancelled before entering the log. Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observed the mirror. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md index 9f30dc67f8..79abfab2a0 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes. +The stdio and ACP bins duplicated environment loading, fail-loud handling, entry validation, and boot logic, including subtle Loader failure behavior. Their copies had already drifted and lived in self-executing files excluded from unit coverage, making their helper exports unusable. ## 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..72172144d0 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,17 +13,17 @@ 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 ### Why not keep them? -The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. +Unsupported vocabulary can return when a real consumer exists. `durationMs` remains because durable audit timing is useful independently of a current reader. Bridge-specific payload construction stays in each bridge, while shared durable-event normalization belongs in the protocol library. ## Verification -`HookDialect` is two-valued (`rg "'native'"` in the hooks packages returns nothing); `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer, while `durationMs` stays on `hook/result` and in the fixtures with the replay scrub intact; the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`, with per-hook `timeoutSec` still overriding; and the truncation rule and decision-string rule are defined once, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. +`HookDialect` contains only Claude and Codex, and `suppressOutput` is absent from source, parsed-field docs, and normalization. `durationMs` remains in events and fixtures with replay scrubbing. The `600_000` and `500` defaults each live once in the protocol library, per-hook timeout overrides still apply, and both bridge suites exercise the library-owned stderr truncation and decision rules. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 13c3635477..7253f8a1fe 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -11,13 +11,13 @@ Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: ## Decision -`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for. +Hardcode the existing handshake identity `{ name: 'deepseek-harness-acp', version: '0.0.1' }` at initialization and remove the unreachable config fields and duplicate defaults. Replace `toolKindFor` with neutral `'other'` at both presenter fallbacks. Normal first-party presentations are unchanged; malformed or failed presentations now render an honest generic card instead of inferring a kind from the tool name. Initialize tests and snapshots pin the handshake; only the malformed calls in `hook-codex-posttool-block` change fallback card kind. ## Alternatives considered ### Why not keep them? -`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call. +Branding can return when the app package exposes it to deployments. Inferring presentation from unknown tool names violates the render-intent contract; neutral fallback cards also preserve raw input for malformed calls and broken presenters. ## Consequences 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..5f05c92317 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 @@ -4,7 +4,7 @@ Status: implemented ## Problem -The harness has two test tiers: keyless unit `.spec.ts` (the 100%-per-file coverage gate) and real-API `.e2e.ts` (key-gated, self-skipping in CI). Neither continuously verifies the **complete output transcript** an ACP editor (Zed) sees on its stdin/stdout. The existing ACP e2e ([examples/acp-agent/tests/acp.e2e.ts](../../../../examples/acp-agent/tests/acp.e2e.ts)) is the closest end-to-end check, but it is key-gated and asserts on a handful of *structured fields* (`stopReason`, a `tool_call` title), not the byte-for-byte stream of `session/update` frames. That leaves the "green units, broken product" gap: every unit test can pass while the actual editor-facing protocol output regresses — the same class of failure that shipped the inject bug ([docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md)), where 178 hand-mounted tests stayed green while a real Zed session crashed instantly. +Unit tests do not exercise the complete ACP subprocess transcript, while real-API tests are nondeterministic and key-gated. Editor-facing `session/update` output can therefore regress despite green unit coverage, as the [default-export postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) demonstrated. The blocker for a full-transcript test is the model: the agent's output is driven by a non-deterministic LLM, and a key-gated test that hits the real API on every run is neither deterministic nor CI-runnable. We want the fidelity of a real run with the determinism of a fixture. @@ -12,17 +12,15 @@ This RFC records the decision to add a third test tier — **snapshot tests** ## Decision -A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it over real ACP stdio with a deterministic input script, and diffs its (normalized) output against committed golden files. The model is made deterministic by **recording a real run's session log once** against the real API and **replaying it** on every subsequent run. The committed fixture IS the persisted session JSONL — the same append-only log the harness writes for any session. +A snapshot test boots the real ACP example, drives its stdio protocol from a deterministic script, and compares normalized output with committed goldens. A session log recorded once from the real API supplies all later model streams. The fixture is the product's ordinary persisted JSONL. ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message` events carry the harness's behavior (token usage rides on `assistant/message.usage`). One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). - -An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. +Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral golden. ### Replay derives the model script from the log -The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. +`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling. ### The in-memory replay entry honors the full LLM contract @@ -34,41 +32,40 @@ The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/l | { kind: 'hang' } ``` -`chunks` is what the log derives. The other two cover the LLM contract's failure branches the log **cannot** reconstruct 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* (a timing behavior, not chunk content). A scenario needing those supplies an optional `/replay.override.json` (a `ReplayEntry[]`) that **replaces** the derived script. The `throw` entry carries any prefix chunks so a mid-stream failure replays its partial output before throwing — the "honor cross-seam contracts on BOTH sides" defensive pattern. Synthesizing throw/cancel from the log's `turn/end {kind:error|aborted}` was rejected: it would couple `llm-replay` to loop-internal turn-closing semantics and the `turn/end` reason is lossy (it can't distinguish a thrown 401 from a finish-error). An explicit sidecar is the cleaner seam. +Logs derive chunk entries. Pre-stream throws and hangs have no reconstructable chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons. ### Positional replay, one in-flight stream -Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This is deterministic **only when at most one model stream is in flight at a time**. The first cut runs one ACP session per scenario, which guarantees that. Multi-session concurrency (the bridge multiplexes N sessions, which can prompt concurrently) would let scheduling decide which model call consumes which entry — so concurrent-session snapshots are out of scope until entries are keyed by request rather than position. A scenario whose control flow changes the number/order of model calls must be re-recorded; the cursor **fails loud on overrun** rather than silently reusing or skipping an entry. A missing `session.jsonl` in replay fails loud too ("record first") — never a silent skip. +Replay is positional and therefore permits only one in-flight model stream per scenario. Concurrent-session snapshots require request-keyed entries. Changed call order requires re-recording, and missing or exhausted fixtures fail loudly. ### Recording harvests the log; keyless replay needs a providerless config Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot boot the normal config as-is — `examples/acp-agent/cordis.snapshot.yml` is an include-overlay of `cordis.yml` that disables the `llm-deepseek` entry by id and inserts `llm-replay` (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)); every other entry IS the live tree, loaded through the include. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. +Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with `llm-replay` while retaining the live composition. Recording uses the ordinary config and a harness-supplied persistence root. Replay mode skips `.env` loading, so a stray API key cannot trigger a live call. See the [single-source config RFC](2026-07-04-single-source-acp-replay-config.md). ### Two surfaces: normalize, then compare A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct: 1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). Compared against a committed `stdout.golden.jsonl`. -2. The **re-persisted session JSONL** — the log the replay run itself persists, compared against the scenario's `session.jsonl`. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. There is no separate session golden: `session.jsonl` is both the replay source (recorded scenarios) and the expected produced log. Both sides pass through `normalizeSessionLog` before comparing — the fixture is raw-harvested (its own real session id / cwd / timestamps) and the replay output has fresh ones, so each is scrubbed against its own volatile values (the fixture's read from its header line) and the comparison is on normalized form. Every stored JSONL additionally scrubs composed prompt text to `{{system}}`; each header class's pinning scenario stores that prompt readably in `system-prompt.golden.md` and keeps the complete tool schemas in its JSONL, while other scenarios scrub schemas to `{{tools}}` ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)). For an authored override scenario the same `session.jsonl` holds the expected produced log; `replay.override.json` drives the model, and `llm-replay` ignores the fixture for model chunks when an override exists, so committing the expected log there does not affect replay. +2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. -The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `assistant/message.usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. +The surfaces are complementary: stdout covers bridge projection, while JSONL covers loop, tool, and boundary structure that the projection omits. -Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the compare: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The committed `stdout.golden.jsonl` is itself **JSONL** — one compact, normalized record per line, in the same shape as the wire (NDJSON on the wire, JSONL on disk), so it stays `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the stdout golden store and the `-u`/`--update` "accept the diff" workflow; the session log is checked with a plain normalized-string equality against `session.jsonl`, NOT `toMatchFileSnapshot` (which would overwrite the fixture). +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout golden remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout golden; normalized session equality never overwrites the replay fixture. ### Isolation: normalization now, sandbox later - -Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the executor's existing secret-scrubbing env (`/KEY|SECRET|TOKEN/i`), the fresh non-login `bash -c` per call, and the normalization pass — **not** from an OS sandbox. A real rootless sandbox (bwrap on Linux, sandbox-exec/Seatbelt on macOS) is the established cross-platform pattern (Claude Code, Codex), but it is per-OS, fragile on newer kernels (Ubuntu 24.04+ AppArmor blocks unprivileged user namespaces), and unnecessary for transcript determinism. It is reserved as a future tier via the documented `BashExecutor` capability seam ([a sandboxing executor replaces dsh-bash-local without touching a tool schema](../architecture/2026-06-13-capability-seams.md)) — a new `bash-*` package, not a change here. Scenarios keep bash commands tightly constrained (no `date`/`env`/background/large-output) so the temp-dir tier suffices. +Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. It does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package -The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/support/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. +`@deepseek-ai/dsh-llm-replay` is a support package rather than example-local glue. It replaces the real adapter by short-circuiting `llm/stream` with streams reconstructed from JSONL, and its package placement keeps the replay logic under normal coverage gates. ### 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 @@ -78,6 +75,6 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack ## Consequences -A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log, which doubles as the expected re-persisted log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the `stdout.golden.jsonl`, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the fixture and the stdout golden — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples. +The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the temporary cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. This RFC relates to but does not supersede the [proposed determinism RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index e567c4ff24..ea2eb6964a 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -6,7 +6,7 @@ Status: implemented The harness leans hard on real-API tests by policy: [docs/testing.md](../../../testing.md) argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. -But until this change **nothing in CI ran it**. The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless — it carries no secret, runs on every push and PR including from forks, and stays green for any contributor. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so even if ci.yml invoked it, a keyless runner would skip it green. The real-API safety net therefore only fired when a developer happened to run it locally with a key in their environment — i.e. unreliably, and never as a merge gate. +The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless: it carries no secret and runs for forks. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so adding it there would report green without exercising the real suite. A separate secret-bearing workflow is required to make real-API coverage a merge signal. This RFC records the decision to add a **second, secret-consuming workflow** that runs the real-API suite in CI, and — because introducing the first CI secret into a repo that may later go public is a security/isolation decision — the threat model it relies on and what changes when the repo becomes public. @@ -20,11 +20,11 @@ ci.yml's value is that it is keyless, forkable, and always-green: any contributo ### Cost is not the constraint; reliability is -The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all matching `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy. +Internal inference cost is not the limiting constraint, so the workflow optimizes for coverage and signal. It runs every matching `*.e2e.ts` file on multiple triggers and every trusted PR, implementing the [docs/testing.md](../../../testing.md) with-key policy. ### Triggers: trusted events only -`workflow_dispatch` + `push` to `main`/`master` + nightly `schedule` (`17 0 * * *`, 08:17 Asia/Shanghai) + `pull_request`. Push gives a post-merge signal; schedule catches drift in the external API itself even with no commits; dispatch is the manual escape hatch; `pull_request` gives a pre-merge gate. The user explicitly chose to include PR runs for the pre-merge signal, accepting the larger key-exposure surface that implies (see § Security). +`workflow_dispatch` + `push` to `main`/`master` + nightly `schedule` (`17 0 * * *`, 08:17 Asia/Shanghai) + `pull_request`. Push gives a post-merge signal; schedule catches external-API drift; dispatch is the manual escape hatch; and trusted pull requests get a pre-merge gate. That pre-merge signal deliberately accepts the larger key-exposure surface described under § Security. ### The untrusted-PR gate @@ -50,15 +50,15 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. - **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. -- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value, not its length. (An earlier draft echoed `${#KEY}`; dropped as needless metadata.) +- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length. ### Scope, runtime shape -Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the primary line): these tests exercise API integration, not node-version compatibility, which ci.yml's Node 22.19/24/26 matrix owns. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `14`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled. +The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal. ## Security -Introducing the first CI secret is the part of this change that warrants a recorded threat model, because the natural question — *"can anyone who opens a PR steal the key?"* — has a non-obvious answer, and the answer shifts when the repo goes public. +The repository's first CI secret requires a recorded threat model because access differs between same-repository, fork, and Dependabot pull requests and changes when the repository becomes public. ### Who can reach the secret today (private repo) @@ -69,7 +69,7 @@ So "everyone who could open a PR can steal it" is false: only the write-access s ### The residual exposure the `pull_request` trigger adds -Because PR runs are enabled, the key is handed to **the code on a write-access author's PR branch** — code under review, not yet merged — which is a strictly larger surface than `push`-to-main + `schedule` + `workflow_dispatch` alone (where the key only ever touches already-merged or manually-dispatched code). This was the explicit round-1 tradeoff: the pre-merge real-API gate is worth it for a trusted internal write set and a low-value (internal, free) key. If that calculus changes, the hardening is one line — drop the `pull_request` trigger — keeping post-merge + nightly + on-demand coverage. +Because PR runs are enabled, the key is handed to **the code on a write-access author's PR branch** before merge. This is a larger surface than `push` + `schedule` + `workflow_dispatch`, accepted for a pre-merge signal within the trusted write set. If that calculus changes, drop the `pull_request` trigger while retaining post-merge, nightly, and on-demand coverage. ### What changes when the repo goes public diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 2a6f8b7ae3..45348cfd2e 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -32,4 +32,4 @@ Reviewers lose one artifact name that made the expected persisted log visually s ## Implementation note -The comparison normalizes BOTH sides, but each against its OWN volatile values, not a shared context. A raw harvested `session.jsonl` bakes in the recording run's session id, cwd, and timestamps; the replay run produces fresh ones. `normalizeSessionLog` scrubs cwd by exact string match, so normalizing the fixture against the *replay* run's cwd would leave the recorded cwd in the header unscrubbed and the compare would fail. The harness therefore derives the fixture's normalize context from its OWN header line (`{ type:'session', id, cwd }`) — `fixtureContext()` in `dsh-acp-snapshot`'s suite module — so both sides scrub to the same `{{sessionId}}`/`{{cwd}}` tokens. An authored fixture copied from the old golden already carries the normalized header (`id:'{{sessionId}}'`, `cwd:'{{cwd}}'`), which yields those tokens as the volatile values and scrubs idempotently. The session-log side uses a plain normalized-string `toEqual`, NOT `toMatchFileSnapshot`, so a run never overwrites the fixture. +Each side is normalized against its own header values because recording and replay have different ids, paths, and timestamps. `fixtureContext()` derives the fixture context from its header, making already-normalized fixtures idempotent. Session logs use plain equality rather than file-snapshot updates, so comparison never rewrites fixtures. diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index a60d487e91..14db415b1b 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -27,7 +27,7 @@ Record where a session's **inherited** prefix ends, persist it, and have the rep - **JSONL**: a `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`). - **SQLite**: a `seed_length` column on the `sessions` table. -The SQLite change is a breaking table-layout change, so `SCHEMA_VERSION` bumps. This branch added `seed_length` under version **3**; it later merged with the session-surface branch, which had independently shipped its OWN version-3 layout (the `source_event_seqs`/`surface_op` columns). Because an on-disk `3` is ambiguous between the two sibling layouts, the merged build is version **4** (every column), and an on-disk `3` is rejected like any other non-current version. Per the repo's pre-release stance (§ "Pre-release stance" in AGENTS.md) the backend **rejects** a non-current `user_version` on open rather than migrating it — there is no persisted user data to preserve, so no migration code is written (the existing reject-not-migrate path at `openDatabase` already enforces this; v1, v2, and the collided v3 are all rejected). +The SQLite layout containing `seed_length`, `source_event_seqs`, and `surface_op` is schema version 4. Earlier version 3 layouts were ambiguous, so every non-current `user_version` is rejected without migration under the pre-release policy. ### 3. Replay derives a child script after the boundary diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index d114b25176..8135f6afd1 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -19,7 +19,7 @@ Replay is keyed **per calling session**, and the harness harvests **every** sess ### 1. The calling session id rides on the model request -`GenerateOptions` gains an optional `sessionId`, stamped by the agent loop from `agent.session.id` at request-assembly time (where the session is already in scope). Adapters ignore it; it exists so an `llm/stream` listener can route a call by WHICH session issued it. It is typed `Branded<'SessionId'>` (from `dsh-brand`) rather than importing `SessionId` from `dsh-session` — that package imports `Message` from `dsh-llm`, so importing its id back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a real id assigns with no cast. (A future dedicated ids package could own the brand and dissolve the note; tracked separately — it touches every id import and does not belong in this testing PR.) +`GenerateOptions` gains an optional `sessionId`, stamped from `agent.session.id` during request assembly. Adapters ignore it; an `llm/stream` listener uses it to route by the issuing session. Its type is `Branded<'SessionId'>` (from `dsh-brand`) rather than `SessionId` from `dsh-session`, because that package imports `Message` from `dsh-llm` and importing back would create a cycle. The types are equivalent, so a session id assigns without a cast. Moving the brand to a dedicated ids package remains separate work because it would touch every id import. ### 2. Replay binds live sessions to recorded scripts by first-call order @@ -29,7 +29,7 @@ Live session ids are freshly random every run and never equal the recorded ones, This keys by WHO calls, not by global call order — so it stays correct even if subagents ever run concurrently or in the background (a global cursor would interleave them). A call carrying no `sessionId` (a direct unit-test `stream()`) is treated as one anonymous session bound to the primary script, so the single-session path is byte-for-byte the old behavior. More distinct live sessions than recorded scripts is a fail-loud error (an unrecorded subagent appeared), never a silent mis-route. -The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site. +Child fixtures sort by `createdAt`, which matches call order while siblings run strictly sequentially. The id tiebreak only makes degenerate collisions deterministic. Concurrent or background children must introduce an explicit first-call ordinal instead of relying on timestamps. ## Alternatives considered diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 47db2cd793..edfcc18585 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -10,7 +10,7 @@ That is the tier a mocked unit test structurally cannot be: it exercises the REA ## Decision -Two coupled changes, in one PR: +The implementation has two coupled parts: ### 1. The ACP example ships BOTH hook bridges diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md index 358aa64147..70730f0382 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -8,7 +8,7 @@ Status: implemented ## Decision -`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry (matched by id AND asserted by `name`, so a reused id can never disable the wrong plugin) and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with an optional name assertion, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). +`cordis.snapshot.yml` includes the live config, disables the named DeepSeek adapter by id and name, and inserts the replay adapter. Every other entry therefore comes from the shipping tree. Replay selects the overlay; recording still boots `cordis.yml`, and the load guard permits the intentionally disabled entry. One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included. diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 2f631a5d8a..b3b8831a32 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -6,13 +6,13 @@ Status: implemented The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). -A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was already triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness (`TODO(acp-test-harness)`). Location also decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. +A second ACP example could only copy record, normalization, and harvest logic that must stay consistent. Code under `examples/` also sat outside the package coverage gate, and the original harness could only cancel permission requests. The shared package makes the machinery measured and lets scenarios script approval answers. ## Decision The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) (`@deepseek-ai/dsh-acp-snapshot`); an example's `*.snapshot.ts` is its scenario table, its agent paths, and one factory call, over its own `snapshots/` fixtures and `cordis.snapshot.yml` overlay ([single-source replay config](2026-07-04-single-source-acp-replay-config.md)). Reading `DSH_SNAPSHOT` stays at that edge — the library takes a resolved `mode`. -**`src/harness.ts`** — `runScenario` and the input-script/result types, parameterized by an `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`; absolute paths the consuming suite resolves from its own `import.meta.url`). The client's `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. +**`src/harness.ts`** provides `runScenario` and its script/result types, parameterized by the agent's bin and config paths. Permission answers form a FIFO queue keyed by stable option kind rather than random option id. Missing answers cancel the request; an unavailable kind cancels the agent request and fails the scenario. **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. @@ -29,7 +29,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su ## Testing -Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the REAL spawn path by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` covers every step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), env forwarding, workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. Two structurally unreachable guards carry reasoned `v8 ignore` comments. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). +Extraction preserved every existing ACP golden byte. The package's `src/` has per-file 100% coverage through a scripted ACP subprocess: harness tests cover every step operation, both expected-error branches, permission selection/fallback/impossible choice, environment forwarding, workspace seeding, and harvest ordering/noise/fallback; suite tests execute replay against committed synthetic fixtures and record against a temporary copy, plus the pure helpers. Two structurally unreachable guards retain reasoned coverage exclusions. The fake agent substitutes the `session/new` cwd into logs, including Darwin's `/var` realpath behavior, matching the real bin. ## Consequences diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index e1792d3ca3..9eb4c585a4 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -6,14 +6,14 @@ Status: proposed The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention. -The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. Two concrete consequences surfaced in review of [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md) (#33): +The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. The [session-persistence contract](../../implemented/architecture/2026-06-14-session-persistence.md) exposes two consequences: 1. **Persistence treats `event.data` as opaque JSON.** The JSONL/SQLite backends `JSON.stringify`/`JSON.parse` each event verbatim; the only runtime guard is `isJsonValue` (round-trip serializability — rejects BigInt, functions, cycles, non-finite numbers, …), NOT structural validation. A corrupted-but-still-JSON event datum (wrong field types, missing fields) round-trips silently and is only caught later, if at all, by a consumer's `switch`. 2. **No runtime contract for plugin-added variants.** A plugin that declaration-merges a new `SessionEventMap` key gets compile-time typing for its own code, but nothing validates that the values it produces match the shape it declared — at the producer, at the persistence boundary, or on reload. -A reviewer asked whether the project should move "all the JSON serialization/deserialization" — and ultimately the event vocabulary itself — to **Zod** (or a similar runtime-schema library), so the durable boundary and the plugin extension points are backed by runtime schemas rather than erased types. +This raises whether the event vocabulary should move to **Zod** or another runtime-schema library so durable and plugin boundaries have runtime schemas rather than erased types. -This RFC scopes that question. It does **not** propose an implementation; it records the tradeoff so the decision is made deliberately rather than incrementally inside a persistence PR. +This RFC scopes that question without proposing an implementation. ## Why this is not a persistence change @@ -32,7 +32,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. -This is a HUGE change. It is not in scope for the RFC-009 session-persistence work and must not be smuggled in through it. +This is a repository-wide vocabulary redesign, not a persistence implementation detail. ## Alternatives considered @@ -46,7 +46,7 @@ Keep the compile-time pattern. Persistence stays opaque-JSON + serializability g Tighten only the genuinely-closed shapes that already have hand-rolled type guards — e.g. the JSONL `HeaderLine` guard (`isHeaderLine`) — using **schemastery** (the repo's existing schema library, already used for every plugin `static Config`). Leave the merge-extensible event union as-is. - **Pros**: small, fits the existing convention (schemastery, not a new lib); replaces hand-rolled guards on closed shapes with declarative schemas; no core redesign. -- **Cons**: does not address event-data validation (the thing the reviewer actually asked about); only helps the fixed metadata records. +- **Cons**: does not address event-data validation; only the fixed metadata records improve. ### C. Runtime schema registry for the whole vocabulary (Zod or schemastery) Replace the merge-extensible maps with a runtime registry the producers contribute to and the persistence/consumer paths validate against. @@ -56,11 +56,11 @@ Replace the merge-extensible maps with a runtime registry the producers contribu ## Proposal -Defer. Do **not** change #33. If runtime validation is wanted at the durable boundary in the near term, **Option B** (schemastery on the closed header/metadata shapes) is the proportionate step and stays within the existing convention. **Option C** is a genuine architecture decision that should be evaluated on its own merits — including whether the chosen library is Zod or schemastery — and, if accepted, land as its own change with its own RFC, not as a side effect of persistence serialization. +Defer. If runtime validation is wanted at the durable boundary, **Option B** (schemastery on closed header and metadata shapes) is the proportionate step within the existing convention. **Option C** is an architecture decision that requires its own implementation RFC, including a choice between Zod and schemastery. ## Acceptance criteria -- The decision state is explicit: Option C proceeds only as its own change with its own implementation RFC — never as a side effect of a persistence PR. +- Option C proceeds only through its own implementation RFC, never as a persistence side effect. - If Option B is taken up, the closed header/metadata shapes (the JSONL `isHeaderLine` guard and kin) validate through schemastery in place of hand-rolled guards, with the merge-extensible maps untouched. ## Risks 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/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 3de3c7d9be..bf4e85e020 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. +Add isolated subagent providers for Claude Code and Codex. The existing [named-provider seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) and [ACP backend](../../implemented/feature/2026-06-22-acp-subagent-backend.md) establish the process-boundary shape. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. ## Proposal @@ -14,7 +14,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. - `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, ignored `request.parent` and `request.agentOptions`, and a random branded agent id. `result` never rejects; child failures map to stop reasons while the original error reaches the logger. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. ## Verified interface facts (pinned versions) @@ -31,11 +31,11 @@ Both integration surfaces were verified against pinned implementations before th ## Isolation and credentials -Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. +Authentication is API-key-only. Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed best-effort on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary values such as `PATH`, `HOME`, `TMPDIR`, locale, and proxy settings, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start` rather than a hand-written auth file. ## Permission and approval policy -Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. +Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with `permission: reject`; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Examples opt into `acceptEdits` or `workspace-write`. Known approval, user-input, and elicitation requests receive the configured answer; unknown methods receive method-not-found and unknown notifications are consumed. No prompt reaches a human, and no child can wait indefinitely for unavailable input. ## StopReason mapping @@ -45,11 +45,11 @@ Liveness posture, stated explicitly: teardown timing is config, turn duration is ## Testing -Named at every tier per the root AGENTS.md rule, and de-risked up front: +Coverage is required at each applicable tier: -- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. -- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. -- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. +- **Keyless unit/integration:** drive a fake Claude CLI through the real SDK and a scripted Codex app-server through the real wire client. At per-file 100% coverage, exercise round trips, every stop mapping, both cancellation paths and pre-abort, permission policies, unknown messages, spawn failure, reload cleanup, export shape, scrubbed environments, temporary-directory removal, and Codex auth precheck failure. +- **With-key e2e:** each real engine performs file work under `acceptEdits` or `workspace-write`; skips name the missing binary or key and assert no child process remains. +- **Snapshot:** deferred as `TODO(claude-code-subagent-replay)` and `TODO(codex-subagent-replay)` pending the process-specific replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). ## Alternatives considered diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md index 79a40b66af..7caceed4ba 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md @@ -4,45 +4,36 @@ Status: proposed ## Problem -A user deep in a live session often wants to fork the conversation — ask "why did we structure it this way", explore an alternative, get an explanation — without polluting the main context and without abandoning the surface they are in. The harness has every primitive this needs and no product face for it: [the session-store fork API](../../implemented/feature/2026-06-30-session-store-fork-api.md) produces a `Session` with no attached agent and no way for a client to reach it, and [the fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) seeds a child with the parent's prefix but runs it as a model-driven task whose whole transcript collapses into one tool result — neither yields a forked conversation the USER can talk to. - -The return path is missing entirely: nothing carries a conclusion from one branch back into another. Whatever the user learns in an exploration branch is copy-pasted by hand or lost, with no provenance and no replayable record. - -Terminal-first competitors ship half of each: single-shot side questions with inherited context, and user-switched branch copies that force a client restart. None ship the return verb. A harness that owns its session store and context assembly can do both cheaply — and can do so without breaking the provider prefix cache, which third-party wrappers structurally cannot. +A user may want to explore a question from a live session without changing its main context. Existing primitives do not expose that product shape: [session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) creates an unattached session, while [fork subagents](../../implemented/feature/2026-06-21-subagent-capability-seam.md) are model-driven tasks whose transcript collapses into one tool result. Neither gives the user a separate conversation, and neither records a conclusion back into the parent with provenance. ## Proposal -A **side session** is an ordinary live session forked from a source session at its last completed turn, attached to its own agent, framed as a read-only advisor, with one new verb — **merge-back** — that hands a condensed note to the parent. +A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note. -- **Fork + attach composes existing primitives.** The child is created via `ctx.agents.create({ seed, meta })` with the parent's balanced completed-turn prefix (the same slice the fork subagent takes) and `parentSession`/`seedLength` lineage stamped in `meta`. No new core service and no session-store change, for the same reasons [the fork API RFC](../../implemented/feature/2026-06-30-session-store-fork-api.md) rejected a standalone fork service. -- **Advisor framing rides the log, not the system prompt.** The rules ("you are a read-only side advisor; explain, do not mutate; refuse task continuation") are `inject()`ed as a `context/message` with source `{ kind: 'plugin', plugin: 'sidechat' }` immediately after creation. The child's system prompt stays byte-identical to the parent's, so the provider's prefix cache covers the inherited history. -- **Merge-back is one condense turn plus one injection.** The child is prompted for a bounded handback note (a hard length cap), which is then `inject()`ed into the PARENT as a `context/message` with the same plugin source. The parent's next request sees it at its chronological position; replay and [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) hold by construction; no new event type enters [the session vocabulary](../../../core-data-structures/session.md). -- **The surface binding is deliberately unspecified.** How a user invokes the fork and the merge, and how a handback note is presented, are client concerns; while the harness speaks through protocols whose UI it does not control, this RFC pins only the surface-agnostic mechanics above and leaves presentation to the first surface the project owns. +- **Fork and attach:** create the child with the parent's balanced completed-turn prefix and stamp `parentSession` and `seedLength` in its metadata. This composes `ctx.agents.create({ seed, meta })`; it adds no core service or session-store method. +- **Advisor framing:** inject one plugin-sourced `context/message` after creation that tells the child to explain without mutating or continuing the task. Keeping the system prompt byte-identical preserves the provider prefix cache over inherited history. +- **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event. +- **Presentation:** invocation, session switching, and handback rendering belong to the first client-owned surface. This RFC specifies only the surface-independent mechanics. -Scope also excludes: rewind productization (mechanically the same fork-to-an-earlier-boundary, an entirely different product question), session tree views, a model-facing side-session tool, and `forkName`/`mergedInto` metadata. - -## Prototype - -A spike (branch `spike/side-sessions-b`) validates the mechanics against the live adapter: a fork that leaves the source log untouched, a child seeded with the full inherited prefix, a multi-turn advisor exchange that demonstrably uses parent history, and a merge-back the parent's next turn quotes correctly. +Rewind productization, session-tree views, a model-facing side-session tool, and `forkName`/`mergedInto` metadata are out of scope. A live-adapter spike validated source-log isolation, inherited context, a multi-turn child exchange, and merge-back visibility in the parent's next turn. ## Alternatives considered -- **Carry side conversations through the subagent seam.** Rejected: a subagent child is a model-driven run — the parent's model spawns it, drives it, and consumes its result as a tool result. A side session is user-driven, needs its own client-visible session and lifecycle, and must outlive any single parent turn. -- **Persona via `system-prompt/assemble` section filtering.** Rejected as the default path: any system-prompt byte change invalidates the provider prefix cache from token 0, forfeiting the cheap fork that makes side sessions attractive on long histories. The filter seam remains available for deployments that prefer hard prompt separation over cache reuse. -- **A dedicated `sidechat/*` event family for the handback.** Deferred: `context/message` with a mandatory plugin source already satisfies durability, provenance, and replay. A first-class event earns its catalog, persistence, and snapshot costs only if a UI needs to render handbacks as dedicated cards. -- **Binding the proposal to a protocol surface now.** Rejected in review: the harness currently speaks through client-owned UIs it does not control, so any presentation contract written today would be speculative. The RFC pins the surface-agnostic mechanics and defers presentation to the first surface the project owns. -- **Surface-level mirroring of the handback.** Rejected in review: a visible record emitted outside the log vanishes on replay while the model still sees it. Whatever surface eventually renders the handback must derive its presentation from the durable `context/message`, so live and replayed views come from the same event. +- **Use the subagent seam:** rejected because side sessions are user-driven, client-visible, and may outlive a parent turn; subagents are model-driven runs returning one tool result. +- **Change the child system prompt:** rejected by default because any byte change invalidates the prefix cache from token zero. Deployments may still prefer that stronger separation. +- **Add `sidechat/*` events:** deferred because a sourced `context/message` already provides durability, provenance, and replay. A dedicated event is justified only by a surface that needs distinct rendering. +- **Bind a protocol surface now:** rejected because current UIs are client-owned. Live presentation must eventually derive from the durable message so replay renders the same record. ## Acceptance criteria -- Forking a live session yields a child agent seeded with the source's balanced completed-turn prefix, with `parentSession` and `seedLength` in its header and a system prompt byte-identical to the parent's; the source log is untouched by the fork. -- The advisor framing is exactly one plugin-sourced `context/message` at the head of the child's appended history — never a system-prompt change. -- Merge-back appends exactly one length-capped `context/message` to the parent with source `plugin: sidechat`; the parent's next request sees it, and replay reproduces it at the same position. -- Parent and child run concurrently without cross-talk between their logs or streams. -- Coverage: unit tests for the fork/attach and merge-back mechanics; surface-level snapshot coverage lands with whichever surface first binds the feature. +- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `seedLength`, and a byte-identical system prompt. +- Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history, rather than changing its system prompt. +- Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position. +- Parent and child run concurrently without log or stream cross-talk. +- Unit tests cover fork/attach and merge-back; snapshot coverage lands with the first bound surface. ## Risks -- **Read-only is advisory in v1.** The rules are injected context, not enforcement; a determined prompt can still drive mutating tools. The hard gate is a `tools/pre-execute` deny via [the interception seams](../../implemented/feature/2026-06-30-interception-seams.md), and the RFC's advisor framing is written so that gate can be added without changing the mechanics. -- **A compacted source forks its compacted view.** The child inherits the summary, not the original turns; whichever surface binds the feature should disclose this once [compaction](../../implemented/feature/2026-06-18-compaction-capability-seam.md) ships in this path. -- **Handback notes spend parent tokens.** The length cap and one-note-per-merge bound the cost, but a user who merges repeatedly accumulates notes; a future consolidation pass belongs to the compaction work, not here. +- Read-only behavior is advisory until a `tools/pre-execute` deny gate enforces it; [the interception seam](../../implemented/feature/2026-06-30-interception-seams.md) can add that gate without changing these mechanics. +- A compacted source forks its compacted view, so a bound surface should disclose that the child inherits summaries rather than replaced turns. +- Repeated handbacks consume parent context. The per-merge length cap bounds each note; later consolidation belongs to compaction. diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 3587393efe..c4ee6161bd 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). The ACP snapshot suite's scenario table (`examples/acp-agent/tests/acp.snapshot.ts`) hand-maintains a `childSessions` count per scenario that duplicates the number of `session..jsonl` fixture siblings on disk. These lists are small today, but every new package or scenario class creates another manual synchronization point. +Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, Knip overrides, and snapshot scenario metadata. Most restate package layout, manifest data, aggregate command contents, or fixture files. Each new package or scenario therefore creates avoidable synchronization points. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 1a69f9ebe6..d94ce11f5c 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -4,35 +4,37 @@ Status: proposed ## Problem -The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. +The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced and persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. -ACP already uses the same value for both identities. Where they diverge, stdio keeps `labelBySession` solely to recover an agent label from session events, and hooks expose both values for authors to reconcile. No production path reattaches one live agent object to several sessions or drives one session through several agent ids. +ACP already uses the same value for both identities. They diverge for config-created agents, resumed sessions, and in-process children, but no production path reattaches one live agent to several sessions or drives one session through several agent ids. Stdio keeps `labelBySession` only to recover an agent label from session events, and hooks expose both values for authors to reconcile. -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no reservation side tables: create and resume use one `AgentCreationTransaction`, and agent/session entries use the same final-entry collision rule. Separate ids therefore do not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification is only an API and representation simplification: it deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no identity-specific reservation state: create and resume use one `AgentCreationTransaction`, and both registry entries use the same final-entry collision rule. Separate ids do not duplicate liveness, rollback, or quiescence machinery. Unification deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle; it also makes the live-agent registry enforce the session identity used by background-task ownership. -Session itself repeats the same fact as `Session.id` and `Session.header.id`. Construction rejects a header whose id differs, so the aliases are constrained equal; the durable boundary must nevertheless validate the duplicate, and production consumers choose between its two homes. +`Session` separately exposes `Session.id` and `Session.header.id` even though construction requires them to match. The durable boundary must validate the duplicate, and consumers must choose between two homes for one fact. ## Proposal -Make an agent's registry id equal its session id. `CreateAgentOptions` accepts one id used for both final registry entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; Session keeps one identity home by deriving `id` from `header.id` or removing the alias. Keep the existing creation transaction, final-entry collision checks, and exact-entry detach semantics; remove only maps and fields whose sole job is translating between the ids. +Use one id for the agent registry entry and `session.header.id`. `CreateAgentOptions` accepts one identity for both final entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; and `Session` keeps one identity home. Preserve the current transaction, final-entry collision checks, exact-entry detach, rollback, and quiescence; remove only maps and fields whose sole job is translating between the ids. -The config-driven path must first settle its currently hidden resume-or-create policy. Today it uses a stable agent label and fresh UUID-suffixed session id to avoid colliding with a durable log on the next run. Under unification it must deliberately resume the fixed id, mint a fresh combined id, or expose an explicit policy; implementation must not pick silently. +The config-driven path must first settle its resume-or-create policy. Today it uses a stable agent label and a fresh UUID-suffixed session id to avoid colliding with an existing durable log on the next run. Under unification it must deliberately resume a fixed id, mint a fresh combined id, or expose that policy; implementation must not choose silently. -`agent/created` and `agent/disposed` remain outside this proposal. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. +`agent/created` and `agent/disposed` remain outside this proposal. They are publication lifecycle events rather than identity aliases; removing them requires a separate production-consumer audit and decision. ## Alternatives considered -**Keep separate routing and log identities.** The config-driven loop uses a stable configured agent id with a fresh UUID session on each fresh process start. That is a real use of the distinction: a stable routing/display label plus a new durable conversation. Unification can proceed only after choosing whether this path resumes a fixed identity, mints a combined per-run identity, or exposes the policy explicitly. If the stable label is a required product contract, reject this proposal rather than hiding it in another map. +**Keep separate routing and log identities.** A stable configured agent label paired with a fresh conversation is a real use of the distinction. If that display or routing identity is required, reject this proposal and enforce session-id uniqueness explicitly instead of hiding the translation in another map. ## Acceptance criteria - Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. -- The existing creation transaction keeps final-entry collision, exact-entry detach, rollback, and quiescence guarantees without adding identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session id translation. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence guarantees without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session-id translation. - The config-driven resume-or-create policy is explicit and covered across a durable restart. -- `agent/created`/`agent/disposed` are removed only if a post-change production search finds no listener; otherwise they and their publication semantics stay. +- `agent/created` and `agent/disposed` change only after a separate production-consumer audit. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks -This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. The config restart decision is blocking, not mechanical. If separate routing identity is a real requirement, reject this RFC and retain the current caller-supplied pair plus final-entry arbitration. +Unification forecloses a stable actor identity spanning several session logs, including a future handoff or fork that preserves the actor while changing the session. Reintroducing that design would require a new explicit actor identity. It also makes a persisted, possibly client-chosen session id the registry handle and changes every create/resume call site and fixture. + +The config restart policy is the blocking design decision: a fixed combined id may collide with its existing log, while a per-run id gives up the stable configured label. If either independent actor identity or the stable-label/fresh-session pairing is required, reject this proposal and retain the separate ids with an explicit uniqueness guard. diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 1af873731c..4c52d0b1a4 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -15,7 +15,7 @@ The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the mock's structured branch and its `capabilities`/`structured` config knobs, and the tests that exist to pin the removed surface (the two rejection rows, the spawn absence test, the mock structured specs). Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, and the README rows in `packages/subagent/subagent`, `packages/subagent/subagent-spawn`, `packages/subagent/subagent-fork`, and `packages/support/subagent-mock`. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). -**Keep** `depthLimit`/`maxDepth` and the capability-check mechanism itself — with eyes open about its current reach. The in-process backend genuinely enforces the cap (`SubagentDepthError` in `packages/subagent/subagent-inprocess/src/index.ts`), but no production request sets `maxDepth` (`tool-subagent` exposes no knob for it), so on the shipped tool path the guard is dormant and recursion is uncapped. The alternative — remove the depth machinery too, on the argument that a dormant guard reads like a safety property while providing none — was considered and rejected: recursion is the seam RFC's named risk, the enforcement is real working code rather than vocabulary awaiting an implementation, and the honest completion is wiring a default cap through `tool-subagent` (a few-line feature) rather than deleting the only existing guard. One live capability row also keeps the two-tier design demonstrated rather than merely remembered. +**Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. Adjacent surface examined and deliberately left alone: `SubagentService.getProvider()`/`list()` have test-harness consumers only, but the [prune-dead-seam-methods implementation note](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records precisely this shape being removed from the bash executor and reverted — a test harness IS a consumer for a one-line accessor over an already-tracked map. `SubagentRunEndInfo.lastAssistantMessage` is a recorded keep (the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)'s review dropped `agentType` and kept it deliberately, as the only final-message channel for out-of-process children); its currently-unwired bridge forwarding is a gap to close or a consumer to document, not surface for this RFC to cut. diff --git a/docs/testing.md b/docs/testing.md index 23cca651fe..2a571d6015 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, and concurrency races; review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). +- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Use `pnpm run test:snapshot:record` when the model transcript should change; use `pnpm run test:snapshot:refresh` when the committed transcript is still the right mock LLM input and replay goldens need keyless rewrite. Review the golden diff. System-prompt/tool-schema content is pinned by ONE scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 6f5c8890bf..51641d4655 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -48,6 +48,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2434d490d4..3f0e8ce820 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,21 +2,8 @@ import stylistic from '@stylistic/eslint-plugin' import sonarjs from 'eslint-plugin-sonarjs' 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. - */ +// Strict type-aware correctness rules plus repository formatting. Tests/examples relax deliberate +// mock unsafety; vendored sources retain upstream style and receive only selected safety checks. export default tseslint.config( { ignores: [ @@ -40,13 +27,8 @@ 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. + // One project service resolves each file to its owning tsconfig and shares dependency + // graphs. Per-package programs duplicated path-mapped and Cordis closures, reaching ~5 GB. projectService: true, tsconfigRootDir: import.meta.dirname, }, diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 46a4eaa27b..045f523c5c 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,27 +1,22 @@ # 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:** private package stubs are not built. App bins load each `cordis.yml` through `tsx`; package names resolve through root `tsconfig.json` paths, not `node_modules`. -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 wiring, demo fixtures, and e2e/snapshot scenarios here. Move reusable logic into `packages/`, with coverage and a README. App bins own bootstrapping; examples have no `start.ts`. -## Every example ships e2e smokes (keyless + with-key) +## E2E smokes -Each example must have **both** kinds of end-to-end smoke, because they catch different failures: +Each example has both: -- **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 and clean exit. Catches Loader/export-shape failures 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 require only the keyless tier; state that 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). +Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `--expose-internals` when loading HMR. -## Current state +Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. -| Example | Keyless smoke | With-key smoke | -|---|---|---| -| `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | -| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit; `tests/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | -| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject | -| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key; `tests/escalation.e2e.ts` boots the default tree (sandbox + approval + permission + bridge) keyless: initialize + `session/new` | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written; `tests/escalation.e2e.ts` — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | +In `cordis.yml`, comment only non-obvious wiring, load-order consequences, replay, security boundaries, and configuration scope. Do not narrate visible entries; use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment. See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 1319acd60b..2cd4ea7a04 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is now a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads ONE app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent @@ -19,7 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. -Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its registry contribution is the reserved `run_code` transport plus a generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. +Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. ## cordis-agent diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 6a48b1f5b0..8f611b7d67 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -This example is one leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge), the DeepSeek adapter, sandboxed bash, approval and permission services, subagent/workflow/todo tools, and the advisory `repeat-tool-guard`. ACP `session/new` creates agents on demand. The app package bakes in the no-stdout-logger cluster, so the leaf has no logger entry to get wrong by default. `demo:code-mode acp` boots the same tree through [`code-mode.cordis.yml`](code-mode.cordis.yml), collapsing the tool surface to `run_code` plus the generated TypeScript SDK (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). +The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with `run_code` and its generated TypeScript SDK; see [Code Mode](../../packages/core/tools/README.md#code-mode). ## stdout is the protocol @@ -33,7 +33,7 @@ The editor sets each session's `cwd` to the project it opens, and bash uses that ## 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. ## Permissions and sandboxing diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index 64802e1da9..5e14c40995 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -1,6 +1,4 @@ -# Keyless replay counterpart to advanced.cordis.yml. It composes the same -# Code Mode + Cordis + subagent + workflow tree while replacing only the live -# model adapter with the committed multi-session replay script. +# Replay counterpart to advanced.cordis.yml; only the live model is replaced. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index 772369578f..39520be5a8 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -1,7 +1,5 @@ -# Advanced ACP snapshot overlay: keep every native tool on the wire, add the -# Code Mode worker, and opt into the self-referential Cordis toolset. The base -# tree already supplies direct spawn subagents and the workflow worker, so this -# composition exercises all four boundaries in one editor-facing ACP turn. +# Add Code Mode and Cordis tools to the base spawn/workflow stack, exercising +# all four boundaries in one ACP snapshot. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 8ee54b3078..09dbe796fd 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -1,9 +1,6 @@ -# Both-mode REPLAY overlay: the same patched tree as both-mode.cordis.yml -# (registry in `mode: both` + the worker code runtime) with the keyless model -# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving -# the recorded fixture). Patches do not compose across nested includes — -# an outer include's patch can only target entries in the file IT loads — so -# this file patches ./cordis.yml directly with the union of both overlays. +# Keyless both mode combines the runtime/registry patch with the DeepSeek-to-replay +# swap. Include patches cannot target entries behind a nested include, so this file +# applies both overlays directly to `cordis.yml`. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index 3dff66d60a..d92a66c250 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -1,11 +1,7 @@ -# Both-mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: both }` -# (every native tool definition stays on the wire AND run_code + the generated -# TypeScript SDK prompt section ride along) and the worker-thread code runtime joins the -# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the -# snapshot harness records the both-mode scenario; DSH_SNAPSHOT=replay swaps -# it for the sibling both-mode.cordis.snapshot.yml. A config patch REPLACES -# the entry's whole config, so the base entry's fields are restated verbatim. +# Both mode adds `ctx.codeRuntime` while keeping native tools on the wire and +# adding `run_code` plus its generated TypeScript SDK prompt. The app bin selects +# this overlay for snapshot recording and the sibling overlay for replay. A config +# patch replaces the whole app config, so unchanged base fields are restated below. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index d525afc5d6..14c36e4399 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -1,9 +1,6 @@ -# Code Mode REPLAY overlay: the same patched tree as code-mode.cordis.yml -# (registry in `mode: code` + the worker code runtime) with the keyless model -# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving -# the recorded fixture). Patches do not compose across nested includes — -# an outer include's patch can only target entries in the file IT loads — so -# this file patches ./cordis.yml directly with the union of both overlays. +# Keyless Code Mode combines the runtime/registry patch with the DeepSeek-to-replay +# swap. Include patches cannot target entries behind a nested include, so this file +# applies both overlays directly to `cordis.yml`. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 323c35b5b4..d7d60f5af9 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -1,12 +1,8 @@ -# Code Mode overlay: the live acp-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: code }` -# (the registry offers exactly one wire tool, run_code, plus the generated -# TypeScript SDK prompt section) and the worker-thread code runtime joins the -# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file for -# `pnpm run demo:code-mode acp` and when the snapshot harness records the -# code-mode scenarios; DSH_SNAPSHOT=replay swaps it for the sibling -# code-mode.cordis.snapshot.yml. A config patch REPLACES the entry's whole -# config, so the base entry's fields are restated verbatim. +# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, +# `run_code`, plus its generated TypeScript SDK prompt. The app bin selects this +# overlay for `demo:code-mode acp` and snapshot recording, and selects the sibling +# replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app +# config, so unchanged base fields are restated below. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index fd8a9dcad2..f69770b4dd 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,30 +1,17 @@ -# Snapshot-test REPLAY overlay: the SAME app tree as cordis.yml, derived from -# it by an include — the one difference is the model backend. A keyless replay -# run cannot boot the real adapter (llm-deepseek's apply() throws without -# DEEPSEEK_API_KEY), so the include patches the live tree at load time: the -# llm-deepseek entry is disabled by id, and the llm-replay entry (which serves -# a recorded session JSONL — no API key, no network) is inserted. Every other -# entry — the app, the bash executor, the fs/subagent/todo tools, both hook -# bridges, the system prompt — IS the live tree, so replay exercises exactly -# what ships and an app-shape change lands once, in cordis.yml. -# -# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. The replay -# fixture path comes from $DSH_SNAPSHOT_FILE (and an optional -# $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. stdout stays -# reserved for the ACP JSON-RPC protocol (the app package loads no stdout -# logger). Patches apply when the include loads the file — a one-shot replay -# boot, so the load-time-only patch semantics are exactly enough. +# Keyless replay includes the live `cordis.yml`, disables the key-requiring +# DeepSeek adapter, and inserts `llm-replay` to serve recorded JSONL without a key +# or network; every other app entry remains shared. +# With `DSH_SNAPSHOT=replay`, the app bin reads `DSH_SNAPSHOT_FILE` and optional +# `DSH_SNAPSHOT_OVERRIDE` from the harness. The one-shot patch applies at include +# load time, and stdout remains reserved for ACP JSON-RPC. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: - # The name is an assertion, not an override: the include skips the patch - # (warning if a logger exists) when the id points at a different plugin, - # so this can never disable the wrong entry. If cordis.yml ever RENAMES - # the id, the patch degrades to a skip — replay output stays correct - # (llm-replay still short-circuits the stream) but the stale patch and a - # futile keyless adapter entry linger until review catches them. + # `name` asserts the target: a mismatch skips the patch and warns only when + # a logger exists. A renamed id leaves a stale adapter entry, but replay still + # short-circuits through `llm-replay`. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 42c8bb46f0..39c12805c8 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,17 +1,7 @@ -# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config -# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek -# run whose persisted log the snapshot harness harvests. The swappable DeepSeek -# adapter, sandboxed bash executor, the ACP server app -# (@deepseek-ai/dsh-acp-agent), and the optional model-facing -# fs/subagent/todo tools loaded below. -# -# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for -# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a -# property of @deepseek-ai/dsh-acp-agent (it contains no logger entry), not a -# leaf convention: there is no logger here to get wrong. -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). +# ACP server and snapshot-record composition. With `DSH_SNAPSHOT=record`, the +# app bin runs the real DeepSeek adapter and the harness harvests its persisted +# log. The bin loads the gitignored root `.env` before this config. This tree has +# no stdout logger or HMR because stdout carries ACP JSON-RPC. # The DeepSeek adapter. - id: llm-deepseek @@ -24,12 +14,10 @@ - deepseek-v4-pro # The default composition confines bash to the workspace and asks before a -# wider retry. Snapshot runs select danger-full-access so the established -# scenarios remain runner-independent; DSH_PERMISSION_MODE provides the same -# explicit deployment/test override outside the snapshot harness. +# wider retry. Snapshots use danger-full-access; DSH_PERMISSION_MODE overrides +# both mode and approval policy for deployments and tests. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' - - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: @@ -53,22 +41,16 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - # The persona: identity + behavior only, nothing about transports or - # tooling — tool guidance lives with each tool plugin (descriptions + - # prompt sections). {{model}} and {{cwd}} are prompt variables the agent - # loop resolves per session (every ACP session carries the client's cwd, - # so the persona can state the workspace). + # Keep the persona to identity and behavior; tool plugins own tool guidance. + # The loop resolves {{model}} and each ACP session's client-supplied {{cwd}}. persona: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. -# The subagent seam + both in-process backends + two model-facing tools, as leaf -# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh -# child) and fork (a child seeded with the parent's completed-turn prefix) are -# both reachable by the model: dsh-tool-subagent is loaded once per backend with -# a distinct toolName (subagent → spawn, subagent_fork → fork), so a multi-child -# scenario can exercise both transports. +# Expose fresh-child `spawn` and completed-prefix `fork` through separate tool +# names so multi-child scenarios exercise both transports. These leaves follow +# the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -95,10 +77,8 @@ toolName: subagent_fork -# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn -# subagent backend above, plus the model-facing `workflow` tool. The model -# writes a JavaScript orchestration script (meta + body); the engine runs it -# in its own worker thread and fans agent() calls out as spawn children. +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -106,22 +86,18 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' -# The model-facing todo_write tool: whole-list task tracking written to the -# session log (todo/write), surfaced to the ACP client as a `plan` update. +# `todo_write` replaces the logged whole list and surfaces an ACP `plan` update. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' -# The repeat-tool-call guard: advisory reminders (injected context, never a -# block) when the model re-issues the same tool call with identical arguments; -# defaults [3, 5, 8]. Loaded here so the snapshot tier exercises the reminder -# transcript (the repeat-tool-guard scenario) — no other scenario repeats a -# call three times, so it is inert everywhere else. +# Identical repeat calls trigger advisory context, never a block, at the default +# thresholds [3, 5, 8]. Only the repeat-tool-guard snapshot scenario reaches them. - id: repeat-tool-guard name: '@deepseek-ai/dsh-repeat-tool-guard' # Filesystem tools do not ride the bash sandbox, so the confined default omits -# them. Snapshot tests and explicit danger-full-access launches keep the -# established filesystem scenarios by enabling the whole stack together. +# them. Snapshots and explicit danger-full-access launches enable the local +# provider, policy, and model-facing tools together. - id: fs-local name: '@deepseek-ai/dsh-fs-local' disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" @@ -136,28 +112,19 @@ name: '@deepseek-ai/dsh-tool-fs' disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" -# The Claude Code hook bridge. `configPath` is PROCESS-LEVEL: it is read ONCE at -# load and the relative `./hooks.json` resolves against the ACP server's launch -# cwd, NOT each `session/new.cwd`. So a single `hooks.json` next to where the -# server starts applies to every session; a project-local, per-session hooks.json -# is NOT discovered (per-session config resolution is a TODO — see the bridge -# README). With no file present the parse fails-soft and the bridge registers -# nothing (a silent no-op). Hooks THEMSELVES run in the session cwd (the bridge -# passes it as the workdir); only WHERE the config is read from is process-level. -# stdout is the ACP JSON-RPC channel — the bridge's warnings go through ctx.logger -# (no exporter here), never to stdout. +# `configPath` is read once at load and resolves from the server launch cwd, not +# `session/new.cwd`; one `hooks.json` therefore applies to every session and a +# project-local file is not discovered. Missing config registers nothing. Hook +# commands still run in the session cwd. Warnings use `ctx.logger`, never stdout; +# see packages/hooks/hooks-claude/README.md for the deferred per-session design. - id: hooks-claude name: '@deepseek-ai/dsh-hooks-claude' config: configPath: ./hooks.json -# The Codex hook bridge, loaded alongside the Claude one. It reads its OWN config -# file (`./codex-hooks.json`, Codex's snake_case five-event dialect) — the two -# bridges cannot share one file, so each owns a distinct path. Same process-level -# read-once semantics and same fails-soft-when-absent contract: a launch cwd with -# no `codex-hooks.json` registers nothing (a silent no-op through ctx.logger, never -# stdout). The example ships both bridges so a scenario can exercise EITHER dialect -# end-to-end by seeding the matching file in its workspace/. +# Codex uses its own `codex-hooks.json` and snake_case five-event dialect; it +# cannot share Claude's file. It has the same process-level, read-once, missing-is-no-op, +# logger-only contract. Shipping both bridges lets a scenario seed and exercise either dialect. - id: hooks-codex name: '@deepseek-ai/dsh-hooks-codex' config: diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 9dee4d6d71..e02f534fa6 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -17,36 +17,18 @@ import { } from '@agentclientprotocol/sdk' /** - * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over - * its stdio, drive it with a real ClientSideConnection, send a real prompt, and - * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns - * and disposes the subprocess in afterEach. Key-gated. - * - * Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs - * WITHOUT a key, since it only needs the server to boot and answer initialize. + * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg + * verifies its filesystem effect; a keyless initialize leg verifies that stdout + * contains only framed JSON-RPC. Each subprocess is disposed in `afterEach`. */ -// 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 child runs from a temp cwd, so its bin and config path are absolute. 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). +// The root tsconfig supplies unbuilt workspace `paths`; making it explicit +// avoids accidental resolution through stale built output. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) interface Spawned { @@ -155,9 +137,7 @@ 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. + // A dummy key boots the adapter; this purity test sends no prompt and makes no model call. const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], { cwd: workdir, env: { @@ -197,17 +177,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. @@ -238,28 +211,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the WORLD, not the agent's self-report: read the file from disk. + // Verify the filesystem effect rather than the agent's report. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') - // And the client saw tool-call activity stream through. 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. + // Bash execute cards hide rawInput, so `presentCall` uses the exact command + // as the title rather than 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') expect(typeof bashCall.title).toBe('string') expect(bashCall.title.length).toBeGreaterThan(0) - expect(bashCall.title).not.toBe('bash') // the old, unhelpful title - expect(typeof bashCall.rawInput).toBe('string') // the exact command - // Capability OFF: no terminal _meta — the ```console text path renders. + expect(bashCall.title).not.toBe('bash') + expect(typeof bashCall.rawInput).toBe('string') + // Without the terminal capability, output uses the console-text path. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined() }, 180_000) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 2e81093fa4..64c55fa179 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -76,12 +76,9 @@ 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 }, - // ACP-facing counterpart to the packaged Python SDK snapshot: Cordis mounts - // a live marker, Code Mode inspects it through the worker bridge, then a - // direct child and a workflow child run before the mount is disposed. This - // class adds both Code Mode and the opt-in Cordis tools to the base tree, so - // it owns a distinct request-header pin. The scripted fixture is authored: - // the value is deterministic cross-boundary composition, not live-model prose. + // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it + // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and + // Cordis plugins require their own request-header pin; the fixture tests deterministic composition. { name: 'advanced-toolchain', hasModelTurn: true, @@ -91,31 +88,15 @@ const SCENARIOS: Scenario[] = [ headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, - // 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. + // Prompt-submit blocks are authored keylessly: they persist a rejected turn + // and hook events without starting a model step, so their logs still compare. { 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). + // SessionStart/SubagentStart are excluded because detached injection races log + // order; SubagentStop writes no transcript, so a golden could not prove it ran. + // Unit tests cover those points; the hook-snapshot-matrix RFC owns the rationale. { 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 }, @@ -130,11 +111,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. Each overlay composes and pins its own header class. { 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 }, // The default tree owns the single Permissions select. Snapshot mode starts diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 2137efac00..1a690367aa 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -17,35 +17,24 @@ import { } from '@agentclientprotocol/sdk' /** - * The default ACP composition (`cordis.yml`) 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 prompt asserts - * a prior denial (the organic denial→marker path lives on the sandbox e2e - * legs and unit tiers), the real model 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) — under the granted mode, - * a temp-dir session cwd is writable either way. + * Exercises the default ACP composition through the real bin and Loader. The + * keyless leg boots sandbox, approval, permission, and bridge services, then + * initializes and opens a session without a model call or runner probe. With a + * key and usable runner, the prompt asserts a prior denial; the model requests + * a wider retry with justification, and a scripted client grants or rejects it. + * The filesystem must show that only the granted retry ran. Missing credentials + * or runner support self-skip; real denial markers remain on sandbox e2e tiers. */ 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)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// The subprocess runs from a temp cwd OUTSIDE the repo; point tsx at the repo +// The subprocess runs from a temp cwd outside the repo; point tsx at the repo // 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. +// Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with +// SANDBOX_UNAVAILABLE instead of producing the denial this flow requires. const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: 5_000, stdio: 'ignore', @@ -96,8 +85,7 @@ function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawn requestPermission(params: RequestPermissionRequest): Promise { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // The scripted human: pick the requested option when the prompt offers - // it; an unexpected prompt shape cancels (fail closed, never grants). + // An unexpected prompt shape cancels without granting. if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, @@ -121,9 +109,6 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) spawned = spawnAcpAgent(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. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) @@ -135,14 +120,10 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa spawned = spawnAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // This tree composes the permission presets over bash-sandbox + approval → - // ONE select advertises, current from the configured default preset. const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['permission', 'workspace-write']]) - // A switch responds with the COMPLETE refreshed state (the spec contract), - // and the new current survives in the response of a second switch. const afterFullAccess = await client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access', }) @@ -153,7 +134,6 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa }) expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['permission', 'danger-full-access']]) - // An out-of-vocabulary value is a protocol error, never a silent default. await expect(client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'plan', })).rejects.toThrow(/unknown permission value/) @@ -175,13 +155,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // The WORLD: the approved escalated retry landed the write. + // Verify the filesystem, not the model's report. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') expect(proof).toContain('ACP_ESCALATION_OK') - // The CHANNEL: the grant came through a real session/request_permission - // prompt attached to the escalating tool call, offering exactly the - // one-shot options. + // Verify that ACP carried the grant with only one-shot choices. expect(permissionRequests.length).toBeGreaterThan(0) const prompt = permissionRequests[0] if (prompt === undefined) throw new Error('expected a permission request') @@ -204,9 +182,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // The WORLD: rejected means the file never appeared. await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow() - // And the rejection really flowed through a prompt (not a missing channel). + // Distinguish a user rejection from a missing approval channel. expect(permissionRequests.length).toBeGreaterThan(0) }, 240_000) }) diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 43e5034eb3..dd7c6dca05 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -17,21 +17,10 @@ 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 for the Claude hook bridge. The process-level `./hooks.json` is + * resolved from a temporary launch cwd and blocks all PreToolUse calls; a real + * model is asked to write there, and absence of the file proves interception. + * The test owns and disposes the ACP subprocess. */ const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) @@ -89,9 +78,8 @@ 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. + // `configPath` is process-relative, so placing the match-all hook in the + // launch cwd selects it; hook commands themselves run in the session cwd. await writeFile(join(workdir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) @@ -110,12 +98,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook // the model, not a turn failure). expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the WORLD: the hook denied execution, so the file must NOT exist — - // a keyword probe a "cheating" agent could fake in prose cannot pass this. + // Verify that the denied hook left no filesystem effect. await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() - // The client still saw a tool_call stream (the model TRIED), and its result - // carried the hook's block reason back as an error. + // A blocked call is still streamed with the hook's reason as an error. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call' || u.sessionUpdate === 'tool_call_update') expect(toolCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index cdc09c92c0..991806d74b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 79d9b94ad9..a6a3371913 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 7155b1c2a6..c6213cd558 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index 72b41e8d89..bc4da17bb0 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 6e8f157ace..5ad12cddff 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 66cdecedfb..a68bb37f9f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index f5bf912125..577f10445b 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 98b4f97fee..ed60d52258 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index ea8a9c2bb7..60235cac75 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index c0b2346444..bfa379815e 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 98b4f97fee..ed60d52258 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 0bcb0245d0..aa033fb673 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 63d9032ca2..d5d4f1c400 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index d8074c0757..f438444dc4 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index c14b05f06a..b926355bd7 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index 5bc88537ce..acc193ad1e 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 864cc3de9f..5aa75c026c 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index c283736334..05832400c8 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 4f2973bcc5..19abb7f418 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index 6ab374dc0a..0e7dcca6f8 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 03c77cae98..4801d9410d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 1e68a6b90a..d5b3ca5d15 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index e39f3694a8..e4c4984fc5 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,2 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index 3e414cacab..8c97f359b7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index be13a31a84..e9243d8a05 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index ce7acef1f3..3905e82ca7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index d31dd2c810..92906adb59 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 19dd593b09..6304582220 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index 19ae540ab1..c4ad3ba541 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index d76b8537f0..5c4a564d26 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index f62af79405..bd1e04bc59 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index 38a3055a16..4bf92f3197 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 93ff9f89fc..5459da1a17 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 19dd593b09..6304582220 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index a2028ef4b8..8cae81a5c9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index e628431ee3..66d8c816be 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index f288d7a05f..1d9d45954a 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index e1438965b7..d040142032 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -5,7 +5,7 @@ {"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index 65ab7ae4e7..651897e850 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -44,7 +44,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index 42d0507e3f..a0901b6297 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 1c85ee81ba..9a26fa2efe 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index 7cc0dd72f2..10198918d6 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index 0c9ef3b472..44fc4402fc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index b7e669238f..08ad14dc9c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index 519d784c79..93b38e33cb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 8cf5765647..26d45699cc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index c66f5676cc..0643740e4f 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index 40c8434937..1059a9cc6c 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index b99e45c699..f060b6b92f 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 1ed804a0d6..041ea02703 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index b188480ad5..9c0bbd37be 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index d3fd50b416..23f13ff3c2 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 5a607d3c7b..5d6713144c 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -33,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_ ## Code Mode -[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one reserved wire transport — `run_code` — plus a generated TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.) +[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) for the execution contract. ```sh pnpm run demo:code-mode # this overlay under the REPL (default UI) diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index ac4ce03570..8b4d22b917 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -1,13 +1,8 @@ -# Code Mode overlay: the live coding-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: code }` -# (the registry offers exactly one wire tool, run_code, plus the generated -# TypeScript SDK prompt section declaring bash/read/write/edit/subagent/ -# todo_write) and the worker-thread code runtime joins the tree as -# `ctx.codeRuntime`. The dsh-stdio-agent bin boots this file for -# `pnpm run demo:code-mode` (the acp-agent example carries the same-shaped -# overlay for the `acp` UI). A config patch REPLACES the entry's whole -# config, so the base entry's fields are restated verbatim; only `tools`, -# the welcome, and the persona's second paragraph are Code Mode deltas. +# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, +# `run_code`, plus a generated SDK for bash/read/write/edit/subagent/todo_write. +# `demo:code-mode` selects this overlay; the ACP example has the same UI-specific +# shape. A config patch replaces the whole app config, so unchanged base fields +# are restated; only `tools`, `welcome`, and the persona's second paragraph differ. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index cf2e267e06..5581a910b1 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,15 +1,8 @@ -# The coding-agent plugin tree: the REPL agent demo. The two swappable -# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for -# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio- -# agent), which bundles the whole agent-core spine (timer, llm, sessions, -# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console -# logger, JSONL persistence, the readline UI, and a pre-created `main` agent. -# -# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only -# dev plugin that needs `--expose-internals` — the `demo:repl` script passes -# it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env -# first. cordis.yml reads them via the `!!js` tag. +# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-agent` +# supplies the agent-core spine, logging, JSONL persistence, readline UI, and `main` agent. +# HMR remains a leaf because it requires Loader internals; `demo:repl` passes +# `--expose-internals`. The app bin loads the gitignored root `.env`; this file +# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. # Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr @@ -28,15 +21,13 @@ - deepseek-v4-pro - deepseek-v4-flash -# Local bash executor for agent-core's tool-bash schema (one of several tool -# stacks in this tree: filesystem, subagent, and todo_write load below). +# Local executor for the app bundle's bash tool. - id: bash name: '@deepseek-ai/dsh-bash-local' config: timeoutMs: 60000 -# The stdio chat app: the whole spine + front-door cluster, configured for a -# REPL agent demo driving a pre-created `main` agent. +# The app bundle pre-creates the REPL's `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: @@ -46,20 +37,16 @@ resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' welcome: 'agent REPL ready. Give it a coding task.' - # The persona: identity + behavior only, nothing about transports or - # tooling — tool guidance lives with each tool plugin (descriptions + - # prompt sections). {{model}} is the prompt variable the agent loop - # resolves from this agent's configured model. + # Keep the persona to identity and behavior; tool plugins own tool guidance. + # The loop resolves {{model}} from this agent's configuration. persona: | You are coding-agent, a coding assistant powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. -# Automatic context compaction: when the derived history approaches the model's -# context window, summarize an older range into a checkpoint so a long-running -# or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the -# agent-loop's `agent/pre-step` seam from the app above). +# Summarize an older range when derived history approaches the context window. +# This leaf consumes `ctx.llm` and the app's `agent/pre-step` seam. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: @@ -70,13 +57,9 @@ maxTokens: 8192 compactionRetries: 1 -# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf -# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh -# child) and fork (a child seeded with the parent's completed-turn prefix) are -# independent backends over the shared dsh-subagent-inprocess driver. Exposing -# both transports is pure config: load each backend, then load dsh-tool-subagent -# once per backend with a distinct toolName (the tool registry rejects a -# duplicate name) — no code change. +# Expose fresh-child `spawn` and completed-prefix `fork` through independent +# in-process backends. Each tool instance needs a distinct `toolName`; the registry +# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -103,10 +86,8 @@ toolName: subagent_fork -# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn -# subagent backend above, plus the model-facing `workflow` tool. The model -# writes a JavaScript orchestration script (meta + body); the engine runs it -# in its own worker thread and fans agent() calls out as spawn children. +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -114,14 +95,12 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' -# The model-facing todo_write tool: whole-list task tracking written to the -# session log (todo/write), rendered as a stdio checklist / ACP plan. +# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' -# Filesystem capability stack: local provider, read-before-write/edit policy -# gate, then the model-facing read/write/edit tools. stdio-agent is a single -# session, so relative paths resolve from the process cwd (the workspace). +# Policy loads before the model-facing filesystem tools so writes and edits require +# an observed file. This single-session app resolves relative paths from the process cwd. - id: fs-local name: '@deepseek-ai/dsh-fs-local' config: 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..59da071c39 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,12 @@ 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. A dummy key satisfies adapter boot, but no prompt means + * no model call; the with-key proof lives in `code-mode.e2e.ts`. */ const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) @@ -26,10 +21,8 @@ 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. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. 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/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 512688d88d..3f6e109e3f 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -16,14 +16,9 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' /** - * The Code Mode with-key proof (the RFC's e2e tier): a REAL model under - * `mode: 'code'`, a task that requires composing two tool calls, verified - * against the WORLD — the persisted request header carried exactly - * `[run_code]` as the wire tool list, each sub-call landed as a - * `tool/code-dispatch` event, the file the program wrote exists on disk, and - * the final answer is the program's curated output. Key-gated (see - * vitest.e2e.config.ts); the keyless Loader-path smoke of the overlay lives - * in `code-mode-keyless-smoke.e2e.ts`. + * With-key Code Mode proof: a real model receives only `run_code`, composes two + * sub-calls, writes a file, and returns curated output while the log records + * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 854cf49d2a..932209115f 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -7,25 +7,11 @@ 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. It verifies the compact event + * pair, replacement of older surface nodes, and a final answer after compaction. */ +// FIXME(compaction-snapshot): this is the only full compaction coverage because +// replay cannot serve the summarizer's unlogged model call. let workdir: string | undefined let ctx: Context | undefined @@ -40,18 +26,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 ea223bbad4..f4b86dca4a 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -6,44 +6,22 @@ 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. + * Boots the real example through the stdio bin and `cordis.yml`, covering Loader, + * `unwrapExports`, the full plugin tree, the agent-core bundle, and the readline module. + * A dummy key permits startup; closing stdin before a prompt prevents network calls, + * while with-key suites cover product behavior. */ -// TODO(loader-smoke-harness): extract the shared spawn/tempdir/timeout/EOF -// harness used here, code-mode-keyless-smoke, and cordis-agent's keyless smoke. -// 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. +// TODO(loader-smoke-harness): share spawn/tempdir/timeout/EOF setup with the other keyless smoke tests. +// The temp-cwd child needs absolute bin and config paths. 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: resolve `@deepseek-ai/dsh-*` through the root tsconfig -// `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). +// The temp cwd cannot discover the root tsconfig used for unbuilt package aliases. 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. +// Allow cold Loader startup under parallel load while still detecting hangs. const PROCESS_TIMEOUT_MS = 30_000 -// Leave enough room for the process-owned timeout to report captured output -// before Vitest aborts the test itself. +// Let the child timeout report captured output before Vitest aborts. const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 let child: ChildProcessWithoutNullStreams | undefined diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 953533d35b..1ddf8b1d3b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -30,4 +30,4 @@ Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the gener ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner + clean EOF exit (the export-shape / real-load-path guard, now across the package-name resolution). `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener (asserting the tagged console line actually fires — the world, not the agent's claim), builds itself a `reverse_text` tool and uses it, and composes two mounts via provide/inject. The tool logic itself is unit-tested in [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) under the per-file 100% coverage gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 65d5e6eb36..2634233cec 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,17 +1,11 @@ -# The cordis-agent plugin tree: the SELF-REFERENTIAL harness demo. Same spine -# as coding-agent (DeepSeek V4 + local bash on @deepseek-ai/dsh-stdio-agent), -# plus @deepseek-ai/dsh-tool-cordis, which gives the model three tools over the -# live cordis runtime it is running inside: cordis_inspect (services / plugin -# tree / tools / dynamic mounts / api / events), cordis_mount (evaluate -# model-written code in a vm sandbox and mount the returned plugin under the -# `cordis-dynamic` group), and cordis_unmount (dispose one mount by id). -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-stdio-agent bin loads the gitignored repo-root .env first. -# -# Trust stance (docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md): -# the mounted code gets the REAL ctx — the -# vm sandbox only prevents accidental global pollution. Load the toolset as -# deliberately as you would grant a bash tool. +# Self-referential stdio demo: the coding spine plus tools to inspect the live +# service/plugin/tool/mount/API/event state, mount a model-written plugin under +# `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored +# root `.env` before reading the required DeepSeek key and optional base URL. +# Trust stance: the vm and context façade limit accidental global/framework +# access but are not a security boundary; mounted code can reach live capabilities +# such as `ctx.bash`. Grant this toolset like bash access. See +# ../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. # Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr @@ -53,8 +47,7 @@ - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' -# The stdio chat app: the whole spine + front-door cluster, configured for the -# self-referential demo driving a pre-created `main` agent. +# The app bundle pre-creates the self-referential demo's `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 388fcb0058..32eb90c1ac 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -78,10 +78,9 @@ 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. Model prose is only + // self-report and is deliberately not asserted. 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..24b7fc42cd 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -6,22 +6,16 @@ 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. A dummy key satisfies adapter boot, but no prompt means no network + * call; `cordis-tools.e2e.ts` owns the with-key product proof. */ -// 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. +// The temp-cwd child needs absolute bin and config paths. 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 +23,8 @@ 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. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. 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/cordis.yml b/examples/echo-agent/cordis.yml index b66c5e8163..a476927a89 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -1,9 +1,5 @@ -# The echo-agent plugin tree: the stdio chat app with its LLM backend swapped to -# the local `mock-echo` mock and the local `echo` tool added. The clean -# demonstration of "swap the backend, keep the app" — every service the agent -# needs lives in @deepseek-ai/dsh-stdio-agent (which bundles @deepseek-ai/dsh- -# agent-core); this leaf only picks the backends, `hmr`, and the app config. -# +# Stdio agent with the network-free `mock-echo` adapter and example-local `echo` +# tool. The app bundle supplies the spine; this leaf selects backends, HMR, and app config. # No API key: the `mock-echo` adapter never touches the network. # Hot-module reload for the dev/demo loop (a leaf entry, not baked into @@ -13,8 +9,7 @@ config: root: ['.'] -# The mock model (registers the `mock-echo` adapter) and the demo `echo` tool — -# example-local teaching plugins, resolved relative to THIS file's directory. +# Example-local model and tool plugins resolve relative to this file. - id: mock-llm name: './src/mock-llm.ts' @@ -27,8 +22,7 @@ - id: bash name: '@deepseek-ai/dsh-bash-local' -# The stdio chat app: console logger + the agent-core spine (pre-creating the -# `main` agent on the mock model) + JSONL persistence + the readline UI. +# The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 5db02fb6be..6f15f1bff1 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -6,41 +6,22 @@ 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 mock adapter is network-free, making this the complete + * smoke; inputs cover both the echo-tool round trip and direct-reply branch. */ -// 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. +// The temp-cwd child needs absolute bin and config paths. 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). +// The temp cwd is outside the repo, so point tsx at the root config that resolves +// unbuilt workspace packages through `paths`. 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. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. 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 +48,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/packages/AGENTS.md b/packages/AGENTS.md index 052d9c88f0..1f92a850a9 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -2,17 +2,16 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions). -- **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). +- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **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). - **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. 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). - `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)). +- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. - Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). -- Package READMEs carry `## Known Limitations and Deferred Work` or a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)). +- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index e3692f2558..73236b16b3 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -22,7 +22,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +- **Model-friendly environment** — ambient credential-shaped variables are removed before noninteractive terminal defaults and explicit caller entries are applied. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. Trusted plugins use `env` and `stdin`, but the model-facing tool does not expose them. See the [bash stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. ## Model Experience @@ -37,6 +37,5 @@ Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdou - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them. - **Finished background tasks are never evicted** — they stay in the task map, retaining their in-memory output tails, until executor disposal. -- **`OutputCollector.snapshot()` / `totalBytes` are test-shaped residuals** — the live poll path uses `readFrom()` and a marked cleanup can inline the final snapshot and remove the unused public getter. The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6903c06e32..693cb3b45a 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,16 +1,8 @@ /** - * `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. - * + * Local-subprocess implementation of the bash seam. Each call runs in its own + * process group, background tasks are tracked, and disposal kills and awaits + * them. Execution policy belongs in `tools/pre-execute` or a sandboxing + * executor, not this local process layer. * @module @deepseek-ai/dsh-bash-local */ @@ -90,10 +82,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 +145,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..8ed8b7699c 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -1,23 +1,7 @@ /** - * 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: detached process-group spawn, + * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer + * reacts to an abort signal; the executor owns deadlines and classifies causes. * @module dsh-bash-local/run */ @@ -50,18 +34,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. + * Build a child environment by scrubbing credential-shaped ambient variables, + * applying model-friendly overrides, then merging trusted caller entries last. * - * 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 +237,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. + // A delayed writeback failure makes the spill unreliable; keep finalize + // total but stop advertising that file. this.spillFile = undefined } this.spillFd = undefined @@ -275,13 +248,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 a detached process group. Never throws: delivery races process + * exit and may run in a timer callback, so failures are contained and a + * non-positive pid is a no-op. * @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 +280,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 +294,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 +307,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 +316,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 +328,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/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 450ad7a81f..8f24b4b017 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -337,7 +337,7 @@ describe('LocalBashExecutor background tasks', () => { }) }) -describe('review fixes: lifecycle hardening', () => { +describe('executor cancellation, callback, and disposal contracts', () => { it('start honors a pre-aborted or later-aborted AbortSignal', async () => { const { bash } = await setup() const controller = new AbortController() diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 1d6e93afe3..fde84189cb 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. + // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). + // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. 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 without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. + // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) const result = await runBash(spec('exit 7', { stdin: big })).done expect(result.exitCode).toBe(7) @@ -360,7 +355,7 @@ describe('abort edge cases', () => { }) }) -describe('review fixes: env scrubbing and spill hardening', () => { +describe('environment and spill-file hardening', () => { it('scrubs credential-shaped env vars from child processes', async () => { process.env.DSH_TEST_API_KEY = 'super-secret' process.env.DSH_TEST_TOKEN = 'also-secret' diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index e27045751c..0d0ebca439 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -14,7 +14,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker. -- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. +- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. The model learns standing mode only from tool/result facts, not a system-prompt announcement. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -48,7 +48,7 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc ### Bash tool error, indirectly -**What the model sees**: If no runner can enforce a confined mode, the foreground call fails with code `SANDBOX_UNAVAILABLE` and the exact message `sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.` An execution-time runner failure appends ` Runner failure: `. +**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. **Token effect**: Conditional error text is visible for that call and retained in history until compaction. diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 65d5d8b989..3b8837a7b6 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,43 +1,9 @@ /** - * `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). - * + * Sandbox-consuming bash executor. It wraps the exact local bash argv through + * `ctx.sandbox`, inherits local process mechanics, and reports the selected + * mode, enforcement, and denial facts. Runner failure means the command never + * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background + * tasks carry `runnerFailed`. The tool owns approval and passes per-call modes. * @module @deepseek-ai/dsh-bash-sandbox */ @@ -79,24 +45,11 @@ 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). + * Conservatively classify a nonzero, non-signal run using only the selected + * backend's denial signatures. Text inference may miss a denial or match + * unrelated stderr in that dialect; it never uses another backend's terms. * @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 +57,9 @@ 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. Callers check this before denial because runner diagnostics may + * contain denial words; the command did not run. * @param result - the settled foreground run to classify. * @param signatures - the active wrap's runner-failure signatures, * case-insensitive stderr substrings. @@ -137,15 +82,11 @@ 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 prompt deliberately does not state the mode; each run's - * `result.sandbox` reports what actually executed plus enforcement - * completeness, and the tool layer renders denial or runner-failure facts. + * Registers as `ctx.bash` in place of the local executor and requires a + * `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is + * the fallback, while a session override or approved one-shot escalation may + * select each call's mode. The prompt does not state the standing mode; + * `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { static inject = ['sandbox'] @@ -163,15 +104,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 mode and wrap facts retained until settlement. Overlapping tasks + * may use different modes or provider facts, so one latest-wrap field would + * misclassify earlier completions. */ 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. + // Facts belong to each wrap and may vary between calls. The slow task settles after the + // quick task starts; a shared latest-wrap field would classify and stamp it with the wrong + // task's dialect and enforcement. const wraps: Array> = [ { enforcement: 'partial', denialSignatures: ['permission denied'] }, { enforcement: 'full', denialSignatures: ['read-only file system'] }, diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 8ae25a8d39..7cc58202fc 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,16 +9,11 @@ import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sand import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** - * KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider` - * (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath - * the REAL `SandboxBashExecutor`, driven through the executor's public - * run/start paths. Verifies the WORLD (files exist or don't) plus the - * stamped result facts — in particular that Seatbelt's EPERM denial text - * classifies as `denied: true` through the wrap-carried dialect; the - * backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`. - * - * Self-skips wherever the functional probe fails — every non-macOS host, or - * a macOS whose `sandbox-exec` refuses the profile. + * Keyless macOS integration of the real provider and executor through public run/start paths. + * Linux rungs are forced off so Seatbelt is selected. The tests check world effects and stamped + * facts, including EPERM classification through the wrap-carried dialect; backend-only + * confinement is covered by `@deepseek-ai/dsh-sandbox-local`. Skips off macOS or when + * `sandbox-exec` rejects the profile. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ddb9b92d1d..540eef700c 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -32,7 +32,7 @@ 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). @@ -43,5 +43,4 @@ Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox fac ## Known Limitations and Deferred Work - **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept. -- **Two overlapping surfaces flagged for pruning** — `BashTask.done` duplicates `onTaskDone` (shipped consumers use only the latter), and `get()`/`list()` have test-harness consumers only; both are marked in [the long-running-runtime RFC](../../../docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md). - **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 63c5757175..67e36385f0 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,11 @@ 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()`). + * Registers one `ctx.bash` implementation. Runtime command failures resolve as + * {@link BashRunResult}; only infrastructure failures reject. Background starts + * return immediately without a timeout, report completion exactly once while + * live, and remain cancellable by signal or {@link kill}. Output reads are + * incremental and flag lost buffered data; disposal kills and awaits all tasks. */ export abstract class BashExecutor extends Service { private listeners = new Set() @@ -74,14 +50,11 @@ 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. + * A session or call may override this default, so widening is evaluated per + * execution rather than encoded in this getter. * @returns the configured default mode of a sandboxing executor; * `undefined` for an executor that never confines. */ @@ -90,12 +63,7 @@ export abstract class BashExecutor extends Service { } /** - * Resolve a caller's {@link BashExecRequest} into a fully-specified - * {@link BashExecSpec}, applying this implementation's config defaults and - * caps (working directory, default/max timeout). Consumers (tool layer) - * call this, then pass the result to {@link run}/{@link start} — keeping - * defaulting in the implementation that owns the config while the seam type - * stays explicit (no hidden `?? default` inside run/start). + * Apply implementation-owned defaults and caps to a request before execution. * @param request - the caller's request; omitted fields get this * implementation's defaults, capped fields are clamped. * @returns the fully-specified spec to hand to {@link run}/{@link start}. @@ -125,17 +93,10 @@ 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. + * The executor stores the token without interpreting policy; keeping it here + * lets ownership survive a consumer-plugin reload. * @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 ff0b9ceba1..dfe8c22f4f 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/bash/bash/src/session-mode.ts @@ -1,18 +1,9 @@ /** - * 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 receives neither this event - * nor a standing mode statement. `@deepseek-ai/dsh-tool-bash` names the mode - * only when it renders a sandbox denial. 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 stored as log-only events. Folding the log + * isolates sessions and survives replay; the tool stamps the override onto + * each call unless an approved one-shot escalation outranks it, and the + * executor default applies when neither exists. The model receives neither the + * event nor a standing-mode notice; denial results name the effective mode. * @module dsh-bash/session-mode */ @@ -22,11 +13,9 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's sandbox mode was switched — log-only (like `approval/*`; - * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}); execution and ACP config-option - * reporting fold it without adding prompt text or a context notice. + * Durable log-only sandbox-mode override; never a surface event or model + * message. Execution and ACP option reporting fold the latest event through + * {@link effectiveSandboxMode} without adding a prompt notice. */ 'bash/sandbox-mode': { mode: SandboxMode } } @@ -37,9 +26,8 @@ export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-wr /** * The session's sandbox-mode override: the last `bash/sandbox-mode` event in - * the log, or undefined when the session never switched (callers apply the - * executor's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. + * the log, or undefined when the session never switched and callers should use + * the executor default. Replay needs no separate catch-up state. * @param events - session events in log order (other event types are skipped). * @returns the mode of the last switch event, or undefined without one. */ @@ -52,10 +40,9 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo } /** - * THE write path for a session's sandbox-mode override: appends exactly one - * `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode - * state out of band. Subsequent execution and ACP config-option reporting fold - * it on read; no prompt assembly consumes it. + * Append one `bash/sandbox-mode` event as the only override write path. + * Execution and ACP option reporting fold it on read; prompt assembly does not + * consume it. * @param session - the session the override belongs to. * @param mode - the mode every subsequent bash call in this session runs * under (until the next switch). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 39dbc162c6..d0cb702100 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -71,14 +71,8 @@ export interface BashSandboxInfo { */ enforcement?: SandboxEnforcement /** - * True when the executor classifies this failure as the SANDBOX RUNNER - * itself failing (missing binary, refused profile, fail-closed refusal - * before exec) — the command NEVER RAN; this is a sandbox failure, not a - * task failure, and it outranks `denied` (a runner's own error text can - * contain denial words). Only ever stamped on settled BACKGROUND tasks: a - * foreground run surfaces the same condition as the thrown - * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error - * channel; a settled task's facts are its only channel). + * The sandbox runner failed before executing the command. Set only on settled + * background tasks; foreground runs throw `SANDBOX_UNAVAILABLE` instead. */ runnerFailed?: boolean } @@ -125,17 +119,9 @@ 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. The tool stamps a session override or a + * one-shot approved escalation, with the grant taking precedence. Sandboxing + * executors honor it for this call; non-sandboxing executors do not confine. */ sandboxMode?: SandboxMode | undefined } diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 59c591bc49..3979a3db4b 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -22,7 +22,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. -Result text: stdout, then a `[stderr]` section, then status markers — `[sandbox: file access denied under mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results. +Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. ### `bash_output` @@ -34,29 +34,29 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[sandbo ### Task ownership (cross-session isolation) -The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (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.) +The executor stores the spawning session id as the task's owner. `bash_output` and `bash_kill` reject a caller with a different session id; agent-less tasks remain unowned, while agent-less calls cannot access owned tasks. Storing ownership on the task prevents predictable global ids from crossing ACP sessions and preserves the fence across tool-plugin reloads. Completion notices remain effect-scoped and may be missed during a reload gap. ## 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 while the tool keeps model-facing result text 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 exact command and whose optional description is separate; cwd follows an explicit `workdir`—resolved by the bridge against the session when relative—or the session cwd. Its result carries raw output plus exit or signal data, and clients without terminal support receive a bridge-derived fenced console fallback. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe; malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics. ## Background completion notices -When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. +When a task finishes, the plugin resolves its owner token to a live agent and injects a durable completion notice. If the owner no longer exists, the notice is dropped. Injection affects the next request but does not wake an idle agent, so the model must poll with `bash_output` when it needs completion promptly. ## The tool builds its request from named args only -The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +The seam supports trusted-plugin `stdin` and `env`, but the model-facing tool does not. It builds requests only from its declared arguments, signal, and owner; extra model keys are ignored. Shell syntax already provides equivalent command-level behavior, while the local executor's credential scrub protects ambient secrets. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). ## Permissions and escalation Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md). -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 -Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state. +For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). ## Model Experience diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index c22e789c79..f66838bdde 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -1,57 +1,10 @@ /** - * 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. - * + * Model-facing `bash`, `bash_output`, and `bash_kill` tools over the executor + * seam. Background tasks are fenced by owning session, completion injects a + * durable notice, and confining executors add one-shot approval-based escalation. + * Notices do not wake idle agents. Ownership is stored with the executor task so + * it survives this plugin's reload; per-call authority is escalation grant, + * session override, then executor default. See the package README for the tool contract. * @module @deepseek-ai/dsh-tool-bash */ @@ -74,14 +27,8 @@ export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] /** - * Validate the constraints the SchemaSpec can't express. `defineTool` now - * validates parsed args against the SchemaSpec before `execute` runs (the - * arg-validation RFC), so type/required/enum checks are already done and `args` - * is the validated `InferArgs` shape here. What remains are value constraints - * the DSL has no vocabulary for: non-empty strings, a positive finite timeout, - * and the escalation pairing (`sandbox_permissions` and `justification` travel - * together — an approval prompt without a reason, or a reason driving nothing, - * is a malformed ask). + * Validate value constraints absent from SchemaSpec: non-empty strings, a + * positive finite timeout, and paired escalation mode and justification. */ function validateBashArgs(args: BashToolArgs): void { if (args.command.trim().length === 0) { @@ -105,9 +52,7 @@ function validateBashArgs(args: BashToolArgs): void { } /** - * Reject an empty `task_id`. Type and presence are guaranteed by the - * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the - * DSL can't express, is left to check here. + * Reject an empty `task_id`; SchemaSpec already validates type and presence. */ function validateTaskId(value: string): BashTaskId { if (value.length === 0) { @@ -117,10 +62,8 @@ function validateTaskId(value: string): BashTaskId { } /** - * The bash tool's validated argument shape — the base parameters plus the two - * escalation fields, which are ADVERTISED only when the mounted executor - * reports a confining default mode (absent from the schema otherwise, so the - * SchemaSpec validator rejects them before `execute` ever sees one). + * Validated bash arguments. Escalation fields are advertised only when the + * mounted executor reports a confining mode. */ interface BashToolArgs { command: string @@ -133,10 +76,8 @@ interface BashToolArgs { } /** - * The strictly-wider table: what a call whose effective mode is the key may - * escalate TO. Checked at EXECUTION, never baked into the schema — the - * schema's enum is {@link ESCALATION_TARGETS}, because schemas are - * registry-global while the effective mode is per-call truth. + * Strictly wider modes for each effective mode. Execution checks this table + * because the schema is global while the effective mode is per call. */ const WIDER_MODES: Record = { 'read-only': ['workspace-write', 'danger-full-access'], @@ -144,24 +85,16 @@ const WIDER_MODES: Record = { } /** - * The closed escalation-target vocabulary — every mode a call could ever - * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised - * whenever the mounted executor confines: cutting the enum down to the modes - * wider than the executor's DEFAULT would strand a session whose effective - * mode sits below it (a `danger-full-access` default would advertise nothing - * while a narrower-switched session stays confined with no lever). + * All possible escalation targets. Advertise the global set because a session + * override may be narrower than the executor default; execution rejects a + * target that is not wider for that call. */ 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 byte-stable base description. Escalation guidance is added + * only when the mounted executor can honor it, as the one exception to the + * ordinary no-retry guidance. */ function bashDescription(escalationModes: readonly SandboxMode[]): string { const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. ' @@ -173,15 +106,15 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { + 'poll it with `bash_output` and stop it with `bash_kill`.' if (escalationModes.length === 0) return base return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the ' - + 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it ' - + 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry ' + + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' - + 'approval prompt raised by that retry IS how the user consents. If the session states approval ' + + 'approval prompt raised by that retry is how the user consents. If the session states approval ' + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' - + 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one this command ' + 'just hit; escalating up front is fine only when this session already denied the same access. ' - + 'A rejected escalation is final for THAT command — stop and explain, never work around ' + + 'A rejected escalation is final for that command — stop and explain, never work around ' + 'it — but it does not forbid attempting or escalating other commands later.' } @@ -192,15 +125,15 @@ 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 model-visible stdout, marked stderr, and status + * facts. Non-zero exits and sandbox denials remain ordinary results; only + * infrastructure failure or abort makes the tool call itself fail. + * * @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, @@ -218,15 +151,12 @@ export function renderResult( if (body.length === 0) body = '(no output)' const markers: string[] = [] - // The sandbox marker precedes the exit-status markers so `[exit code: N]` - // stays the LAST line (exitStatus() anchors its parse there). Denial is a - // reported fact like timeout: the model decides how to react. + // Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial, + // like a timeout, remains a reported fact for the model to handle. if (result.sandbox?.denied) { markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) - // The same-turn nudge lives at the decision point: only when this - // composition advertises the fields (a lever is never hinted that the - // schema does not offer), and inside the sandbox marker family so the - // exit-code marker stays the last line. + // Add the retry hint only when the schema advertises escalation, before + // the final exit marker. if (escalationModes.length > 0) { markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') } @@ -247,33 +177,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. -// --------------------------------------------------------------------------- +// Pure tool-owned presentation used for both live events and replay. /** - * 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). + * Present foreground calls as terminals and background starts as generic cards. */ type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean } @@ -289,8 +196,7 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView content: [{ type: 'text', text: args.description }], } } - // A foreground run IS a terminal: the command titles the card, the description - // renders above it, and the cwd (when the model gave a workdir) heads it. + // A foreground run is a terminal; an explicit workdir supplies its cwd. return { card: 'terminal', title: args.command, @@ -300,26 +206,8 @@ 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`. + * Present completed foreground output as a terminal; background acknowledgements + * and execution errors use generic fenced output without an exit-status pill. */ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined { const block = result.content.length === 1 ? result.content[0] : undefined @@ -331,35 +219,14 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | if (isBackground || result.isError) { return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } } - // A finished foreground run: RAW output + parsed exit for the terminal card. + // A finished foreground run supplies raw output and parsed exit status. // The bridge derives the no-capability fenced fallback from `output`. return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } /** - * 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 exit status from the final marked line emitted by {@link renderResult}. + * A program whose own final line exactly mimics a marker remains ambiguous for UI display. */ function parseExitStatus(text: string): { exitCode: number } | { signal: string } { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) @@ -375,15 +242,8 @@ 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 an explicit workdir first, making a relative one session-cwd-relative; + * otherwise use the session cwd and leave executor defaulting as the fallback. */ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { const sessionCwd = exec.agent?.session.header.cwd @@ -404,9 +264,7 @@ function statusLine(task: BashTask): string { } export function apply(ctx: Context): void { - // The bash tools' cross-call HABIT, which the per-tool descriptions cannot - // carry (they describe one call each): the exit-code marker is only useful - // if the model actually checks it every time. + // Cross-call guidance belongs in the prompt rather than one tool description. ctx.systemPrompt.section({ name: 'tool:bash', order: 105, @@ -414,26 +272,15 @@ export function apply(ctx: Context): void { }) /** - * The caller's owner TOKEN — the owning agent's `session.header.id`, or - * `undefined` for a non-agent caller. Read `session.header.id` (NOT - * `session.id`): every other subsystem keys off the header id (the ACP bridge, - * both persistence backends), and the sibling `resolveWorkdir` already reads - * `session.header.cwd`, so using `session.id` here would be the asymmetry smell - * the conventions flag. The two are equal in production, but the header is the - * canonical identity. + * Return the canonical session-header id used by ACP and persistence as the + * task owner, or undefined for a non-agent caller. */ const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined => exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined /** - * Authorize a `bash_output`/`bash_kill` call against the task's stored owner - * token. Rejects when the task HAS an owner and it differs from the caller's - * token — using `!== undefined` semantics, NOT truthiness, so an empty-string - * token is still a real owner (never treated as unowned). An unowned task - * (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also - * `undefined` here and then fails loudly at the subsequent - * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller - * (`callerToken` undefined) cannot match an owned task and is rejected. + * Reject access when a task has a different session owner. Unowned tasks are + * allowed; unknown ids still fail in the subsequent read or kill. */ const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => { const owner = ctx.bash.ownerOf(taskId) @@ -442,15 +289,8 @@ 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. + // Completion runs on the bash fiber, so use topology-independent lookup and + // match the executor's stored session-owner token to a live agent. ctx.bash.onTaskDone((task) => { const ownerToken = ctx.bash.ownerOf(task.id) if (ownerToken === undefined) return @@ -462,62 +302,38 @@ 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. + // Advertise the closed target vocabulary globally, then enforce strict + // widening against each call's effective session mode. const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS /** - * The session's standing mode override for an ordinary (non-escalating) - * call: the `bash/sandbox-mode` fold of the calling agent's log, stamped - * onto the request so execution follows the fold without stating it in the - * prompt. Weakest precedence — an escalation grant (freshly approved for - * exactly this call) outranks it, and without either the executor's - * `resolve()` applies its configured default. Undefined for a non-sandboxing - * executor (nothing honors it) and for agent-less callers (no session to - * fold). + * Return the calling session's folded standing mode. Approval outranks this + * value and the executor default applies when it is absent; non-sandboxing + * and agent-less calls have no override. */ const sessionOverride = (exec: ToolExecution): SandboxMode | undefined => defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events) /** - * Resolve a sandbox-escalation request through `ctx.approval` BEFORE - * anything executes. Returns the granted mode to stamp onto the bash - * request; throws the distinct fail-closed text for every other path (no - * service composed, an agent-less execution, a rejection, a cancellation, - * an unanswerable ask) — the registry turns the throw into this call's - * isError result, and nothing has run. The seam is consumed - * opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a - * deployment without it degrades per call, never at registration. + * Request one-shot escalation before execution. Missing approval context, + * rejection, cancellation, and unavailable answers throw without running the + * command; the optional seam is resolved per call through `ctx.get`. */ 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. + // Reject an unadvertised escalation before prompting for a nonexistent sandbox. 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`) @@ -539,8 +355,7 @@ export function apply(ctx: Context): void { ...exec.signal ? { signal: exec.signal } : {}, }) switch (outcome) { - // The SchemaSpec enum already pinned `mode` to the closed target - // vocabulary; the per-call check above proved it is strictly wider. + // Schema validation pins the vocabulary; the per-call check proves widening. case 'allowed-once': return mode as SandboxMode case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`) case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) @@ -580,14 +395,8 @@ 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. Escalation approval + // completes before execution; grant > session override > executor default. const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined ? await approveEscalation(args.sandbox_permissions, args.justification, exec) : sessionOverride(exec) @@ -603,10 +412,7 @@ 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). + // Store the session owner on the task for bash_output/bash_kill isolation. const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) })) return [{ type: 'text', text: `started background task ${task.id}` }] } @@ -640,15 +446,11 @@ export function apply(ctx: Context): void { } text += `\n${statusLine(read.task)}` if (read.task.sandbox?.runnerFailed) { - // The sandbox RUNNER itself failed — the command never ran. The - // foreground path surfaces this as the structured SANDBOX_UNAVAILABLE - // error; a settled task's read carries the marker instead. + // Background settlement carries the runner-failure fact that a + // foreground call exposes as SANDBOX_UNAVAILABLE. 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 12b4a53958..3be0e1145b 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -56,12 +56,8 @@ 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). + // A config agent has distinct registry (`agent.id`) and owner (`session.header.id`) tokens. + // Keeping them unequal makes notice lookup by the wrong field fail instead of passing by chance. 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) ?? [] @@ -418,9 +414,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. + // Notices look up the agent in ctx.agents by session token, so passing it to execute is not + // enough: the fake must be registered with a matching `session.header.id`. const agent = registerFakeAgent(ctx, 'bg', inject) const started = await ctx.tools.execute({ @@ -483,11 +478,8 @@ 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). + // Host-scoped bash tasks can outlive a per-session agent after an ACP disconnect. The task + // retains its owner token, but with no matching live agent the notice is dropped without error. const ctx = await setup() const inject = vi.fn() const agent = registerFakeAgent(ctx, 'bg', inject) @@ -517,11 +509,8 @@ 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 uses `session.header.id`, not object identity. Distinct ids keep the isolation tests + // from passing accidentally because every fake produced the same owner token. const fakeAgent = (sessionId: string) => ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent @@ -601,11 +590,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 executor task owns the token, so reloading only tool-bash preserves ownership. A + // plugin-local map would lose it and incorrectly expose the task to agent B. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -824,11 +810,8 @@ 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 may print marker-like text. A clean result appends no marker or + // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. 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. @@ -891,26 +874,18 @@ 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` soft-validates replayed logged args before presentation. Invalid shapes return + // undefined for generic UI rendering rather than throwing; `presentCall` accepts `unknown`. 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 requests passed to `resolve()` so tests can prove the model-facing tool forwards only + * named arguments. It intentionally exposes neither `stdin` nor `env`; this catches a future + * `...args` spread into the post-scrub env merge. The credential scrub remains the security + * boundary; see the bash stdin/env RFC. Foreground `run()` is canned and `start()` is unused. */ class RecordingBashExecutor extends BashExecutor { readonly requests: BashExecRequest[] = [] @@ -953,12 +928,9 @@ 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.) + // Unknown `env` and `stdin` keys are ignored by the schema and named request construction. + // This preserves the request shape; it is not a security boundary because shell syntax can + // already set environment variables or feed stdin. await ctx.tools.execute({ callId: CallId('no-forward-1'), name: 'bash', @@ -1438,11 +1410,9 @@ 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. + // With a workspace-write default and read-only override, escalation must return to + // workspace-write. The static target vocabulary exposes it, and validation compares it with + // the call's effective override rather than a default-relative ladder. 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/README.md b/packages/code-runtime/code-runtime-worker/README.md index ca7ec93bc5..0c69674e7a 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -29,11 +29,11 @@ Every field is validated (positive numbers) and defaulted; there are no other tu ## The worker entry, unbuilt and built -`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). +Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). ## Model Experience -Indirectly, through Code Mode in `dsh-tools`, which renders this worker's capped printed or returned data, exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers, and `Error: code run failed (): ` failures into a retained `run_code` result while keeping binding traffic and worker internals outside context. +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. ## Known Limitations and Deferred Work diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index f2e0d343f3..db78f9d72e 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,11 @@ 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. It preserves Node's optional callback + * contract: the callback runs asynchronously after admission, even when the log budget drops + * the write. + * * @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 +146,12 @@ 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. Oversized + * or non-cloneable values are replaced by a bounded string rendering with an in-band marker. + * * @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 +205,11 @@ export function wireReplies(port: BootstrapPort, pending: Map { @@ -331,11 +319,8 @@ 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. + // Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker; + // logs captured before timeout, abort, or failure remain in the result. let finishResolve!: () => void const finished = new Promise((done) => { finishResolve = done }) const finish = (result: Omit): void => { @@ -353,11 +338,8 @@ 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 forged completion traffic at the hostile boundary. Honest worker-capped values + // pass unchanged via VALUE_RENDER_SLACK; error text is bounded too. 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..739f1e4eb5 100644 --- a/packages/code-runtime/code-runtime-worker/src/protocol.ts +++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts @@ -1,11 +1,7 @@ /** - * 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. - * + * Versionless, structured-clone wire protocol between co-shipped host and worker code. The host + * treats inbound traffic as hostile because model code can forge `parentPort` messages; the + * worker trusts host replies. * @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 9c9b009b4e..b0550cdaa7 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. - * + * Spawn-only worker entrypoint over {@link runWorkerMain}. Executable logic stays in + * `bootstrap.ts` for in-process coverage; real-worker tests cover this glue. * @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 aac0ece8a1..24006a7c04 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.cjs` - * 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. + * Keyless built-artifact smoke: plain Node imports the package by name through its exports map, + * then exercises type stripping, sibling `worker.cjs` loading, bindings, and logs. Unit tests use + * `src/worker.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent, + * and CI runs it after the build. */ 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..a7386938c5 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 prototype write bypasses the patched instance and reaches the real pipe. Pauses keep + // writes in separate chunks and let both reach the host before settlement. 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 3eec074119..6fee724195 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -1,17 +1,9 @@ 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 CommonJS entry — `new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)))` - * loads it as a file, so it cannot be part of the index bundle. pkg's VFS - * Worker hook compiles string-path entries as CommonJS, so an ESM worker is - * not viable inside the executable. 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. + * Build the index and worker as separate single-entry bundles. The sibling `worker.cjs` is loaded + * by file and must be CommonJS for pkg's VFS Worker hook. A multi-entry build emits an unlisted + * shared chunk omitted by the package's exact `files` whitelist; separate builds inline it. */ 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..e629a855b5 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -1,18 +1,6 @@ /** - * 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 async bindings. + * Runtimes know nothing about tools or sessions; consumers own those concerns. * @module @deepseek-ai/dsh-code-runtime */ @@ -35,26 +23,10 @@ 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()`). + * Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate + * failures resolve in {@link CodeRunResult}; only seam misuse rejects. Implementations bridge + * structured-cloneable bindings while treating programs as hostile peers, isolate runs from + * one another, and terminate and await in-flight runs during disposal. */ export abstract class CodeRuntime extends Service { /** diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 3b703d3082..d9c0f472a8 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -6,16 +6,15 @@ This is the implementation tier of the compaction capability — see the [interf ## What it owns -The abstract contract states only WHAT compaction does; this backend owns every HOW decision: +This backend owns the compaction policy: -- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt. -- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large 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. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. -- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. -- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it. -- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event. -- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README). -- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. -- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. +- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state. +- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. +- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. +- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. +- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. +- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index c0d4ff483a..bf349f7f73 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -1,31 +1,8 @@ /** - * `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. - * + * Basic compaction backend. It estimates request pressure, retains a recent + * tool-balanced surface tail, summarizes the older head through a one-shot model + * call, and replaces that head with one checkpoint. Auto-compaction runs before + * every step so a growing turn can compact its earlier closed steps. * @module @deepseek-ai/dsh-compact-basic */ @@ -54,15 +31,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). + * Fixed summary structure for resumable checkpoints. A tagged prior checkpoint + * is merged with newer history instead of copied forward verbatim. */ const SUMMARIZE_SYSTEM_PROMPT = [ 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', @@ -100,29 +70,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 summary failure to an error. A max-token finish is rejected + * because committing an incomplete checkpoint would shadow the full history. */ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { @@ -164,25 +118,8 @@ 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. + // Check before every step so a single growing turn can compact earlier closed steps. + // This serial pre-step seam mutates the surface outside the pending step. ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => { try { const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) @@ -289,27 +226,9 @@ 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 through a direct one-shot `ctx.llm.stream()` call, not an agent + * step or `agent/request` dispatch. Failure finishes and truncated summaries + * reject; the signal is forwarded and only text reaches the checkpoint. * * @param text - plain-text rendering of the conversation region to condense. * @param agent - supplies the fallback model and the session id stamped on @@ -359,42 +278,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 pressure gate: count the next request's prefix, derived history, + * and system prompt. Above threshold, retain a recent tool-balanced tail and + * compact the head, reconsolidating any prior automatic checkpoint. Returns + * `null` when no safe or necessary range exists. */ override async compactIfNeeded( agent: Agent, @@ -450,13 +337,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 by surface position: a newer replacement seq may occupy an older slot. const nodes = session.surface.nodes const startIdx = nodes.findIndex(n => n.seq === start) const endIdx = nodes.findIndex(n => n.seq === end) @@ -466,14 +347,7 @@ 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. + // Both range edges must preserve assistant tool-call/result pairing. 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 +364,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 +406,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 +461,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 +505,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 +524,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 e47ca434e3..8c5cc183c1 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -82,15 +82,7 @@ function createTestService(overrides: Partial = {}): TestCom return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) } -/** - * Build a multi-turn session with surface markers (simulating real agent-loop - * output). Compaction always runs inside an OPEN turn (the loop fires the - * `agent/pre-step` seam after a turn's start and before a step's start), so by - * default the session is left with a trailing open turn: turns `1..turns` - * close, then one more `turn/start` opens with no matching `turn/end`. Pass - * `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual - * compaction is rejected when no turn is open). - */ +/** Build closed turns plus an open compaction turn unless `leaveOpen` is false. */ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session { const leaveOpen = opts.leaveOpen ?? true const s = new Session(SessionId('test')) @@ -211,12 +203,8 @@ 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. + // Retain the recent tail while the older assistant/result pairs compact as + // whole units; no boundary may orphan a result. const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) @@ -231,12 +219,8 @@ 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 only candidate cut is inside one assistant/result pair; with no safe + // compactable prefix, decline rather than split it. 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 +589,16 @@ 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. + // Role overhead pushes the request above its 48-token threshold, but the + // raw four-node retention walk remains below retainTokens=45, so all fit. 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. + // Completed early steps of the open turn remain eligible; protecting the + // whole turn would make a runaway turn impossible to compact. 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 +638,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 +744,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. + // An orphaned start in a closed repaired turn is stale; only the current + // turn participates in the in-progress lock. const svc = createTestService() const s = new Session(SessionId('stale-lock')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -860,11 +826,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 +1222,8 @@ 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 bypass agent/request but remain mutable at llm/stream; + // adapter selection happens after the waterfall rewrite. ctx.on('llm/stream', (options, next) => { options.model = 'routed-model' return next() @@ -1517,19 +1477,13 @@ describe('BasicCompactService edge cases', () => { const svc = createTestService() const s = new Session(SessionId('empties')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Step 1: an empty-text user, an empty-reasoning assistant with NO tool-call - // (balanced: nothing to answer), and empty context/steering — all extract to - // nothing and are skipped. s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) 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). + // Keep the log pairing-valid while the empty result covers the final message kind. s.append('step/start', { turn: 1, step: 2 }) s.append('assistant/message', { turn: 1, step: 2, @@ -1543,10 +1497,6 @@ describe('BasicCompactService edge cases', () => { const nodes = s.surface.nodes await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - // Every empty-content message (user text, empty reasoning, empty-content - // tool/result, empty context, empty steering) extracted to nothing and was - // skipped — the only surviving line is the assistant's tool-call (which a - // balanced surface requires to answer the tool/result). expect(svc.summarizeCalls[0]!.text).toBe('Assistant: [tool-call: bash({})]') }) @@ -1594,40 +1544,26 @@ 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. + // Replacement makes surface seqs non-monotonic. The next region is a + // positional span even when startSeq > endSeq. const svc = createTestService({ auto: false }) const session = multiTurnSession(4, 1) - // First compaction: shadow the two oldest surface nodes. + // A replacement puts its high-seq summary at the surface head. 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.) 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). const startSeq = nodes1[0]!.seq const endSeq = nodes1[2]!.seq expect(startSeq).toBeGreaterThan(endSeq) const second = await compactRegion(svc, session, startSeq, endSeq, 'm') - // Exactly the three nodes at surface positions [0..2] are shadowed, in - // surface order — the positional slice, regardless of their seq values. + // Selection follows surface positions, not sequence-number order. expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq]) - // The surface still derives cleanly: a new head replace node + the rest. const finalNodes = session.surface.nodes expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq) expect(session.deriveMessages().length).toBe(finalNodes.length) @@ -1637,20 +1573,15 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const svc = createTestService({ auto: false }) const session = multiTurnSession(3, 1) - // First compaction shadows the oldest two surface nodes, landing a high-seq - // summary node at the head. + // Put a high-seq summary at the head; log order would place retained older nodes first. const n0 = session.surface.nodes await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm') - // Second compaction spans [head summary … turn-2's step end]. The head's seq - // is higher than the older retained nodes' seqs, so a log-seq-order walk - // would emit the older messages BEFORE the checkpoint. const n1 = session.surface.nodes svc.summarizeCalls = [] await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm') - // The extracted transcript follows surface order: the checkpoint (head) - // first, then the older retained messages — matching deriveMessages(). + // Extraction must match surface and `deriveMessages()` order. const { text } = svc.summarizeCalls[0]! const checkpointIdx = text.indexOf('compacted-summary') const olderIdx = text.indexOf('turn 2 user') @@ -1661,10 +1592,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 1efb417b48..dbf8a3b737 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,10 @@ 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 through the real loop. A replacement checkpoint has a high + * log seq at the surface head and carries no tool pair, so both adjacent cuts + * must be safe and re-compacting that checkpoint alone must succeed. This pins + * surface-position semantics rather than raw-log scanning. */ const TOKENS_PER_BLOCK = 10 @@ -132,14 +118,8 @@ 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. + // High log position does not make a text-only checkpoint mid-step; both + // its start and end cuts are balanced in surface order. const nodes = agent.session.surface.nodes for (const cp of checkpoints) { const node = nodes.find(n => n.seq === cp.seq) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 49330f7647..f59e0dc328 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -1,23 +1,9 @@ /** - * 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 (`dsh-compact-basic`) / 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). - * + * Compaction service seam (`ctx.compact`): implementations decide when to + * compact and replace a history range with one summary node by subclassing + * {@link CompactService}. This interface necessarily depends on session and LLM + * vocabulary; the rationale is in the + * [compaction RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). * @module @deepseek-ai/dsh-compact */ @@ -42,25 +28,10 @@ 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. Implementations own token estimation, retention, + * and summarization, but a successful run must replace the selected surface span + * with one summary node and prevent concurrent compaction of the same session. + * Load one implementation per context as `ctx.compact`. */ export abstract class CompactService extends Service { constructor(ctx: Context) { @@ -69,43 +40,17 @@ 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). + * Estimate the next request, including its session prefix, derived history, + * and system prompt. Above threshold, compact a head-anchored range ending at + * a balanced tool boundary and reconsolidate any prior automatic checkpoint. + * Return `null` when no compaction is needed or an open tail leaves no safe + * cutoff. A single oversized retained unit or prefix cannot be repaired here. * * @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; model-backed implementations must forward it. * @returns the compaction result, or `null` if no compaction was needed. */ abstract compactIfNeeded( @@ -117,36 +62,19 @@ export abstract class CompactService extends Service { /** * Forcibly compact a range of surface nodes into a single summary node. + * `start` and `end` name an inclusive span by surface position, not numeric seq + * order; replacements can make visible seqs non-monotonic. Both edges must be + * balanced so assistant tool calls remain paired with their results. A model- + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. * - * `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; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the replaced range and summary. */ abstract compactRegion( session: Session, diff --git a/packages/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts index 6e977df007..48006d13b7 100644 --- a/packages/compact/compact/src/render.ts +++ b/packages/compact/compact/src/render.ts @@ -1,16 +1,6 @@ /** - * 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. - * + * Pure shared transcript projection for summarization and recall, so both + * render the same log span byte-for-byte under replay. * @module @deepseek-ai/dsh-compact/render */ @@ -18,14 +8,9 @@ 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 text directly, reasoning as a tagged span, and every other block as a + * type-tagged placeholder. Tool results recurse into nested content; empty + * blocks contribute nothing and rendered blocks join with newlines. * * @param blocks - the content blocks to render. * @returns the newline-joined plain-text rendering; empty string when nothing renders. @@ -59,17 +44,9 @@ 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 message-producing events as a role-labeled transcript. `seqs` are + * walked in caller-supplied surface order, which may differ from numeric log + * order after replacement; non-surface and unknown merged events are skipped. * * @param events - the session log the seqs index into (`session.events`). * @param seqs - the surface-node seqs to render, in surface order. diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index ba9834910f..10a5eabfcc 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -1,17 +1,9 @@ /** * 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. - * + * Those declaration-merged events are log-only lock/provenance markers, not + * surface events; a separate replacement `user/message` carries the summary. + * Backend packages own configuration and retention policy; see + * `docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md`. * @module @deepseek-ai/dsh-compact/types */ diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 486b07c189..fdc4e51e6b 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata ## Trust stance -The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. These traps steer honest code onto the cordis services; they do not contain a mount that goes looking — the host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are reachable functions, so mount code can reach the host realm and Node through one of them, which is fine because `ctx` is fully privileged anyway. The `ctx` a mounted plugin's `apply` receives is a whitelist façade — register tools, observe events, provide/consume services, use timers; framework internals (`ctx.root`, `ctx.fiber`, `ctx.extend`, `ctx.plugin`, …) are withheld — but the capabilities it does expose reach the real runtime, so load this plugin as deliberately as you would grant a bash tool. +The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Config diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f3d3c01cb4..6b19fd38d6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -77,14 +77,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'approval', - summary: 'The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent\'s session log.', + summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', methods: [ 'async request(req: ApprovalRequest): Promise', ], }, { key: 'bash', - summary: 'Abstract bash execution service.', + summary: 'Registers one `ctx.bash` implementation.', methods: [ 'abstract resolve(request: BashExecRequest): BashExecSpec', 'abstract run(spec: BashExecSpec): Promise', @@ -99,7 +99,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'codeRuntime', - summary: 'Abstract code-execution service.', + summary: 'Registers one `ctx.codeRuntime` implementation.', methods: [ 'abstract run(request: CodeRunRequest): Promise', ], @@ -114,7 +114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'fs', - summary: 'Abstract filesystem provider service.', + summary: 'Abstract filesystem provider.', methods: [ 'abstract resolve(path: string, opts?: { cwd?: string }): Promise', 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise', @@ -136,7 +136,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'permission', - summary: 'The permission service (`ctx.permission`).', + summary: 'Owns the deployment\'s permission presets and their write path.', methods: [ 'current(events: readonly SessionEvent[]): string', 'resolve(name: string): PresetSpec', @@ -153,7 +153,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionPersistence', - summary: 'Abstract durable session-persistence service.', + summary: 'Durable append-only session storage.', methods: [ 'abstract create(meta: SessionHeader): Promise', 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', @@ -206,7 +206,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service for the prompt inputs assembled before each model step.', methods: [ 'section(section: PromptSection): () => void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', @@ -216,7 +216,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'tools', - summary: '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.', + summary: 'Tool registry and execution pipeline.', methods: [ 'register(definition: ToolDefinition): () => void', 'restrict(filter: ToolRestriction): () => void', @@ -246,7 +246,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'workflows', - summary: 'Abstract workflow execution service.', + summary: 'Workflow execution seam.', methods: [ 'abstract start(request: WorkflowStartRequest): WorkflowRun', ], @@ -259,13 +259,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.', + summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - summary: 'An agent was removed from the registry.', + summary: 'An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind.', }, { name: 'agent/error', @@ -277,37 +277,37 @@ 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 serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step.', }, { 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: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', }, { name: 'agent/queued', mode: 'emit', signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void', - summary: 'A message entered the agent\'s inbox (queued or steering).', + summary: 'Detached, frozen content entered the agent\'s inbox.', }, { name: 'agent/request', mode: 'waterfall', signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', - summary: '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).', + summary: 'Replace the frozen call configuration.', }, { 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: 'Compose request-only messages placed before derived history.', }, { name: 'agent/session-start', mode: 'emit', signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - summary: 'The agent\'s session lifecycle began, fired once before its first turn.', + summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', @@ -325,37 +325,37 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/turn-continuation', mode: 'waterfall', signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', - summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.', + summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', - summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.', + summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.', }, { name: 'approval/request', mode: 'waterfall', signature: '\'approval/request\'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise', - summary: 'Waterfall asking the composed answerers to decide one approval request.', + summary: 'Ask composed answerers for one decision.', }, { name: 'fs/edit-intent', mode: 'waterfall', signature: '\'fs/edit-intent\'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>', - summary: 'Single-slot decision: produce the optional version guard for the next FileSystem.editText.', + summary: 'Single-slot decision for the next FileSystem.editText.', }, { name: 'fs/observed', mode: 'emit', signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void', - summary: 'Record that an actor observed a target at a version, after a successful read/write/edit.', + summary: 'Record a successful observation.', }, { name: 'fs/write-intent', mode: 'waterfall', signature: '\'fs/write-intent\'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise', - summary: 'Single-slot decision: produce the write intent for the next FileSystem.writeText.', + summary: 'Single-slot decision for the next FileSystem.writeText.', }, { name: 'llm/stream', @@ -367,25 +367,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/created', mode: 'emit', signature: '\'session/created\'(this: Scoped, session: Session): void', - summary: 'A session was created in the store.', + summary: 'Creation announcement during session publication.', }, { name: 'session/disposed', mode: 'emit', signature: '\'session/disposed\'(this: Scoped, session: Session): void', - summary: 'A previously announced session left the store.', + summary: 'Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin.', }, { name: 'session/event', mode: 'emit', signature: '\'session/event\'(this: Scoped, session: Session, event: SessionEvent): void', - summary: 'An event was appended to a session log (sync, fire-and-forget).', + summary: 'Post-commit, fire-and-forget append feed.', }, { name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - summary: 'Awaited durability checkpoint.', + summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { name: 'skill/provider-added', @@ -427,13 +427,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - summary: 'Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.', + summary: 'Expert waterfall over the assembled sections, tools, and variables.', }, { name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', - summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).', + summary: 'Emitted when any prompt provider changes.', }, { name: 'tools/change', @@ -445,25 +445,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/execute', mode: 'waterfall', signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - summary: 'Around-dispatch waterfall wrapping the registry\'s core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.', + summary: 'Around-dispatch waterfall for timeout, retry, or metrics.', }, { name: 'tools/post-execute', mode: 'waterfall', signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, 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: 'Accept, replace, enrich, or block a normalized dispatch result.', }, { 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: 'Allow, deny, or ask before dispatch.', }, { name: 'tools/result', mode: 'emit', signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', - summary: 'Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.', + summary: 'Observe the frozen, lossless-JSON final outcome.', }, { 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..8c9f0e2f45 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 and labels for Cordis's `FiberState` const enum. A const enum has no runtime + * object to import, so these values mirror the pinned vendored definition while retaining its + * type. * @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 3e1c70e461..2198533376 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -1,50 +1,14 @@ /** - * 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. + * 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 of lifecycle-safe verbs and declared services; + * framework internals and context-valued service returns are denied. * + * VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and + * shape-checked before session logging. Common JSON-Schema spellings are normalized when they + * have one meaning; invalid vocabulary fails during registration with a teaching error. * @module @deepseek-ai/dsh-tool-cordis/guard */ @@ -102,7 +66,7 @@ function normalizeSchemaProp(value: unknown, path: string, forceRequired = false } // On an object property a JSON-Schema-style `required` ARRAY names required // children (handled by the nested unwrap below); everywhere else `required` - // must be a boolean, and `false` simply reads as optional. + // must be a boolean, and `false` means optional. const nestedRequiredArray = type === 'object' && Array.isArray(value.required) if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) { throw new Error(`harness.defineTool ${path}.required must be a boolean when present`) @@ -195,14 +159,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. Non-JSON or wrong-shape output fails that call instead of poisoning + * the session log. * @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 +197,9 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void { } /** - * 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 +213,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): (() => void) => sandboxRegisterTool(ctx, tool), schemas: () => ctx.tools.schemas(scopeOf(ctx)), @@ -320,15 +271,8 @@ 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. + * Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared + * injected services. Framework plumbing is denied, and service methods cannot return a Context. */ function sandboxContext(ctx: Context): Context { const tools = sandboxTools(ctx) @@ -348,16 +292,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 +344,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..321546c8c3 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -1,37 +1,9 @@ /** - * 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). - * + * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin + * under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, + * so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent + * accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real + * runtime. Named exports preserve loader injection metadata. * @module @deepseek-ai/dsh-tool-cordis */ @@ -75,9 +47,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..b483b13712 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,13 @@ 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 catalog against the live runtime: live catalogued services with methods, + * uncatalogued live services with owners, absent loadable services, referenced type shapes, and + * inherited Context APIs. * @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..fbed4cc161 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. + * Await the group, mount and settle one guarded child, and dispose it before rethrowing any + * startup failure so a failed mount never lingers. A valid unresolved inject may remain pending. * @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..99a68b062f 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -1,20 +1,10 @@ /** - * 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. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`, + * `ctx.bash`, and Cordis timers. This keeps cooperative mounts inspectable and disposable but + * is not containment: host-realm helper functions remain an escape route. * @module @deepseek-ai/dsh-tool-cordis/sandbox */ @@ -35,17 +25,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. + * Patch only VM constructors so `instanceof` accepts both VM values and host values passed as + * arguments, events, or service results; host intrinsics remain untouched. */ const DUAL_REALM_INSTANCEOF_PRELUDE = ` (hostIntrinsics) => { @@ -156,13 +137,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. Parse errors include the offending line and a TypeScript-removal or bracket- + * balance hint. * @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..09011c77de 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. + // VM-realm objects fail the session prototype-identity check; normalize them into host JSON. 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,8 @@ 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 registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject + // it as this call's error before it corrupts the next request. const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -150,10 +142,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. + // These common JSON-Schema spellings each have one DSL meaning, so normalize rather than + // consume another model turn with a rejection. const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -535,10 +525,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..c27d34d4c3 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -2,13 +2,10 @@ import { describe, expect, it } from 'vitest' import { call, setup, text } from './helpers.ts' /** - * The sandbox context façade is a whitelist, not a pass-through proxy: mount - * code reaches only the registration/eventing verbs, the timer helpers, a - * guarded `tools`, and its injected services. Every framework-plumbing member - * that could hand back an UNGUARDED context — through which a plugin could - * `ctx..tools.register({…})` to bypass the marker check and host-realm - * normalization — is denied. These are the regression guards for that escape - * class (the review finding on the original pass-through proxy). + * The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only + * registration/eventing verbs, timer helpers, guarded tools, and injected services. Framework + * members that expose an unguarded context are denied because they could bypass marker checks and + * host-realm normalization; these tests pin that escape class. */ /** Mount a plugin whose `apply` touches one framework member, and report the error text. */ @@ -77,10 +74,8 @@ 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 escape the façade; service-return + // guards reject that Context before the registration lands. const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -104,10 +99,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 +187,8 @@ 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. + // Without declared inject, Cordis cannot park the consumer when its provider unmounts. The + // façade refuses access up front instead of leaving a zombie tool. 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 +221,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/README.md b/packages/core/agent-core/README.md index 95b6ac07f3..fac4f4819a 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -2,7 +2,7 @@ The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. -This is the package to read to see **the whole shared plugin tree at once**: the teaching overview of the spine behind every app package. +Read this package for the whole plugin tree and its composition order. ## The tree it loads @@ -47,7 +47,7 @@ The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loo ## Why a code bundle, not a shared YAML include -A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, means the default leaf for an ACP server has no logger entry to copy wrong — "the ACP app never logs to stdout" stops being a prose warning a leaf must remember and becomes the app package's default shape (a leaf can still add a sibling logger, so the rule stays documented — but it has nothing to get wrong by default). Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. +A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. ## Model Experience @@ -55,6 +55,5 @@ Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and ## Known Limitations and Deferred Work -- **FIXME: package name and location imply product core** — rename `dsh-agent-core` to `dsh-demo-bundle` and move it under `packages/support/`; it is a demo composition bundle, not the product spine. - **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. - **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index d12ef3bad5..14ffbd6133 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -1,47 +1,9 @@ /** - * 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. - * + * Default executor-less, UI-less agent spine. It bundles the common services, + * concrete loop, local skill provider, and model-facing bash/skill consumers; + * deployments still choose the LLM adapter, bash executor, and presentation. + * The plugin intentionally exposes named exports only because Loader default + * unwrapping would discard its `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-agent-core */ @@ -73,16 +35,13 @@ 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, 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. + * The schema intersects the owners' schemas, which supply defaults for every + * optional input and keep validation from drifting. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -124,12 +83,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..0bf0660364 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -187,15 +187,8 @@ 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 `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard. 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/README.md b/packages/core/agent-loop/README.md index db0afb3d2b..91ed9d5049 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle. +Concrete `ReactLoopAgent` implementation and loop driver. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -8,18 +8,16 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md). -The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. +Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach. -IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. - -- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -40,7 +38,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Configured agents start automatically. `cwd` applies only to fresh sessions; `resumeSessionId` retains persisted metadata. They use the deployment persona. Programmatic setup can shadow it per agent. This plugin supplies the per-agent `model` and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Exported concrete class @@ -50,53 +48,9 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh ### Loop lifecycle (`loop.ts`) -The internal loop driver runs one agent for its whole lifetime: +The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. -``` -create agent → emit agent/session-start(source) ⟵ once, before turn 1 -forever: - wait for queued messages (idle) - TURN (error-contained): - 'turn/start' - each queued: waterfall agent/prompt-submit → allow (→ session('user/message'), - inject additionalContext) | block (→ session('prompt/blocked'), drop) - if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn - STEP loop: - drain steering - assembly = await systemPrompt.assemble(assembleContextFor(agent)) - ⟵ renderPrompt(assembly) IS the full prompt - prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen - session prefix; on the header, never history - await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step; - pressure gates see the prefix the request carries - boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame, - session('step/start') strictly before step/start - config = waterfall agent/request ⟵ frozen seed; return a replacement to switch - session('request/header'[-delta]) ⟵ the header event this request owes the log - stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk') - message = waterfall agent/step-result - session('assistant/message') - each tool-call: session('tool/call') - → tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification] - → session('tool/result') - append buffered post-execute additionalContext as session('context/message')(s) - drain steering → session('steering/message') - cont = waterfall agent/turn-continuation → ContinuationDecision - ({action:'continue', reason?} records reason as next-step steering) - pending steering can override an ordinary stop - terminal = serial agent/turn-stop → ContinuationStop | undefined - (after ordinary decision/reason/steering folding) - if terminal stop, or ordinary action==stop with no pending steering: break - session('turn/end') - await session/flush - terminal turn: discard steering added before/during close and flush; keep ordinary queued sends - ordinary turn: re-enqueue leftover steering as queued - idle unless more queued -``` - -Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. - -Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) +Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. ### What belongs to plugins @@ -128,4 +82,3 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p - **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. - **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. -- **`runLoop`/`Inbox`/`InboxMessage` stay exported with no outside consumer** — [removal is proposed](../../../docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md). diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ff66f942be..a2288c65b7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -48,10 +48,8 @@ export interface PreparedReactLoopAgent { } /** - * Construct one concrete agent together with unforgeable, instance-bound - * lifecycle controls. The package surface deliberately exposes neither source - * subpaths nor this helper: setup code may identify the concrete class, but it - * cannot publish or start the factory's unpublished instance. + * Construct an unpublished concrete agent with instance-bound lifecycle + * controls. Only those paired controls can publish or start this instance. * @param ctx - the agent-loop service context used for driving and events. * @param id - the concrete agent identity. * @param options - loop options for the agent. @@ -131,16 +129,7 @@ export class ReactLoopAgent implements Agent { * leave it set to wrongly drop a later prompt. */ private cancelRequested = false - /** - * The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`), - * read by the driver loop's marker branches so a turn dropped in a - * marker-only window (pre-step / continuation, where no `AbortController` - * carries the reason) ends with the SAME `{kind:'aborted', reason}` the - * mid-step abort path produces from `abort.signal.reason`. Without this the - * caller's `cancel(reason)` would be silently replaced by the literal - * 'cancelled' whenever the cancel landed outside a running step — making the - * logged reason race-dependent and the public `reason?` param half-effective. - */ + /** Pending cancellation reason, preserved even outside an active step signal. */ private cancelReason = 'cancelled' private disposed: Promise private resolveDisposed!: () => void @@ -179,11 +168,7 @@ 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). + // Settle first so a throwing status listener cannot starve quiescence waiters. if (status !== 'running') this.settleIdleWaiters() agentEvents(this.loopCtx, this).emit('agent/status', status) } @@ -269,18 +254,8 @@ export class ReactLoopAgent implements Agent { // Decide the durability checkpoint from the log: an accepted one-shot // turn must be flushed even when its message append was the failing step. 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. + // Keep inject() synchronous: report checkpoint failures live instead of + // rejecting the caller, and track the task so disposal still drains it. 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) => { @@ -290,10 +265,7 @@ export class ReactLoopAgent implements Agent { agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) }) 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. + // Retire on either settlement path. const retire = (): void => { this.pendingIdleFlushes.delete(flush) } void flush.then(retire, retire) } @@ -301,15 +273,7 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { - // 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 only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / @@ -329,29 +293,14 @@ 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 immediately when idle with no queued work, on the next quiescent + * idle transition otherwise, or after driver exit when already disposed. + * This observes quiescence; it does not own teardown. */ 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. + // Agent-owned waiters survive concurrent fiber disposal. return new Promise((resolve) => { this.idleWaiters.push(() => { resolve(this._status === 'disposed' ? this.done : undefined) @@ -387,12 +336,7 @@ 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. + // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) } @@ -432,11 +376,8 @@ export class ReactLoopAgent implements Agent { // cleanup. The normal loop contains turn failures itself; allSettled is the // 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 registry/session/scope disposers. + // Repeat because settled flushes retire in adjacent promise reactions; + // allSettled keeps reporting failures from skipping ownership teardown. 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 4ad4779176..6cd66f622c 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -74,12 +74,9 @@ function signalAbortError(id: AgentId, signal: AbortSignal): Error { } /** - * One create/resume transaction from caller ownership through unpublished - * setup, rollback-covered publication, and final quiescent teardown. - * - * The class deliberately owns the state machine in one place. Registries only - * arbitrate identity at their final `enter()` calls; before that point every - * resource is private to this transaction. + * Caller-owned create/resume transaction through rollback-covered publication + * and quiescent teardown. Resources remain private until the final registry + * entry arbitrates identity. */ class AgentCreationTransaction { private active = true diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 38e92d8586..15cc0aa410 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -1,9 +1,7 @@ /** - * The agent loop driver: one `runLoop()` invocation drives one agent for its - * whole lifetime. Error-contained at the turn level — a throwing plugin ends - * the turn, never kills the loop. See the JSDoc on `runLoop()` for the full - * lifecycle pseudo-code. - * + * Drives one agent across queued durable turns. Turn failures are contained so + * later work can run; the session log, not this driver, owns conversation state. + * See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md. * @module dsh-agent-loop/loop */ @@ -25,33 +23,12 @@ import type { Inbox } from './inbox.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } -/** - * Normalize an arbitrary thrown value into a coded Error. A real Error passes - * through (its `code`, if any, is preserved by {@link errorData}); a non-Error - * throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the - * original value chained as `cause`, so a bad throw still carries a routable - * code instead of degrading to a bare message. - */ +/** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): CodedError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } -/** - * 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. - */ +/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ function finishError(finish: FinishReason): CodedError | undefined { switch (finish.kind) { case 'error': { @@ -78,19 +55,7 @@ function errorData(err: CodedError): { message: string; code?: string } { return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } } -/** - * 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`". - */ +/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { case 'max-tokens': @@ -103,11 +68,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { } } -/** - * Ambient handles the loop driver receives from the agent. Decouples the - * pure function `runLoop` from the mutable ReactLoopAgent fields, making the - * loop testable without a real agent. - */ +/** Mutable agent controls supplied to the loop driver. */ export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ readonly inbox: Inbox @@ -116,122 +77,37 @@ export interface LoopHandle { /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise isDisposed(): boolean - /** - * Whether a `cancel()` is pending for the current turn. The driver checks this - * at every decision point where a turn could start or continue (right after - * the idle wait, after the `running` flip, before each step, and at the - * continuation gate) and drops the about-to-run / continuing turn. Reset once - * per loop iteration via {@link clearCancel} after the turn returns, so the - * marker governs exactly one cancellation and never leaks to a later prompt. - */ + /** Whether cancellation is pending for the current loop iteration. */ isCancelled(): boolean - /** - * The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read - * by the marker branches (pre-step / continuation) so a turn dropped where no - * `AbortController` carries the reason still records the caller's - * `cancel(reason)` value — matching the mid-step abort path. Only meaningful - * when {@link isCancelled} is true. - */ + /** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */ cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** - * Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the - * pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the - * idle wait, so no `running→idle` transition fires to settle a `whenIdle()` - * waiter that was registered in the pre-step window — this settles it directly - * (it 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 idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void } /** - * 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 - * ``` + * Drive queued batches as durable turns until disposal. Plugin failures end the + * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). * @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 prefix and request-header state; conversation history remains in the session log. const transmission = createTransmissionLog() const { session } = agent - // The fused agent-subject dispatcher: every agent/* dispatch below carries - // the agent's scope (an `agent.ctx` listener hears only this agent) with - // the subject injected — one spelling, checked by the dev invariants. + // Fused subject and scope carrier for every agent event below. const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { 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). + // Cancellation between wake and `running` skips only the cancelled work; + // a replacement prompt still runs and owns the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -242,18 +118,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). + // A synchronous `running` listener can cancel before `runTurn`; balance the + // status only when no replacement prompt was queued by that listener. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -262,24 +128,13 @@ 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. + // Idle injection can add a turn, so derive the next number from the log. const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { terminalStopped = await runTurn(ctx, events, 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. - // Acceptance and internal dispatch validation can reject before - // turn/start commits. Report that supported pre-turn failure without - // inventing a turn/end for a turn that never opened. + // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { @@ -287,21 +142,10 @@ 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 per iteration, including when a prompt arrives during the flush window. 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. + // Late steering becomes queued input unless terminal policy stopped the turn. for (const message of handle.inbox.drainSteering()) { if (!terminalStopped) handle.inbox.enqueue(message) } @@ -315,10 +159,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. + // Drain before opening the turn, but append only after `turn/start`. const queued = handle.inbox.drainQueued() const first = queued[0] /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ @@ -331,28 +172,17 @@ async function runTurn( let errorReported = false let terminalStopped = false - // Close the open step exactly once (idempotent via stepOpen). Post-commit - // session/event observers are contained by Session; a pre-commit validator - // failure still escapes so the outer recovery path may retry the boundary or - // fail loudly without pretending an uncommitted step/end exists. + // Close the committed step once; pre-commit validation failure still escapes. const closeStep = (): void => { if (!stepOpen) return session.append('step/end', { turn, step }) stepOpen = 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 the durable turn failure once and contain the live error notification. const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // The turn is still open here. Post-commit observers cannot escape append, - // and a pre-commit turn/end veto leaves no closing boundary to overwrite. - // Set the reason that the next successful closeTurn will append. reason = { kind: 'error', step, ...errorData(err) } try { events.emit('agent/error', turn, step, err) @@ -362,9 +192,7 @@ async function runTurn( } } - // Close the turn. Post-commit observer failures are contained by Session; - // pre-commit validation failures escape to recovery instead of being mistaken - // for a committed boundary. Turn boundaries are durable session events only. + // Pre-commit validation failure escapes rather than masquerading as a committed boundary. const closeTurn = (): void => { session.append('turn/end', { turn, reason }) } @@ -414,11 +242,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 closes its zero-step turn as rejected. if (!anyAllowed) { reason = { kind: 'rejected', reason: lastBlockReason } break @@ -437,48 +261,20 @@ async function runTurn( 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 once before pre-step so pressure checks and the request share the same prompt. 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). + // Cancellation or disposal during assembly ends the turn before any step opens. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } - // Compose the 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 request-only prefix once per loop instance before pressure + // checks. It precedes all derived history and is recorded only in the + // request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -486,16 +282,7 @@ 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. + // Never cache an interrupted composition; the next turn recomposes it. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } @@ -504,19 +291,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. + // Await surface mutations outside the step; pressure checks receive the pending prefix. await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal) // Interruption landing during the pre-step seam: do not open an empty step. @@ -526,16 +301,8 @@ 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 the request-window inject seam or a - // concurrent task lands after the boundary and joins the NEXT request. - // session/event itself is observe-only: append reentrancy is rejected - // until the current callback list drains. An external reconstructor - // recovers these exact messages by folding the surface over - // events[0..stepStartSeq). + // Snapshot the exact log prefix before step/start: the reconstruction + // boundary. Appends after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) @@ -582,13 +349,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-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -610,24 +371,16 @@ async function runTurn( break } - // A forced `continue` may carry model-facing context: record it as - // next-STEP steering (the steering channel), so the continued turn's next - // iteration drains it before its request — the typed twin of the /goal - // step/end-steer pattern. + // A continuation reason becomes next-step steering. if (decision.action === 'continue' && decision.reason) { handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) } let shouldContinue = decision.action === 'continue' - // Steering from step/end session-event or continuation listeners (the - // /goal pattern) demands the model see it — it overrides a stop decision; - // the next iteration's drain records it. + // Pending steering overrides an ordinary stop. 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 is monotonic and runs after ordinary continuation folding. let terminalStop = false try { const stop = await events.serial('agent/turn-stop', turn) @@ -640,19 +393,12 @@ 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. + // Terminal stop discards steering but preserves ordinary queued prompts. 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. + // The marker catches cancellation after the step controller was cleared. if (handle.isCancelled()) { reason = { kind: 'aborted', reason: handle.cancelReason() } break @@ -668,19 +414,11 @@ async function runTurn( // Normal / inline-error loop exit: close the turn. closeTurn() } catch (error: unknown) { - // Decide whether this turn opened from the LOG, not a speculative flag. A - // pre-commit validator or acceptance failure leaves no turn/start and owes - // no turn/end, so it propagates to runLoop's backstop. Once turn/start is - // present, this path balances any committed step and records the failure. + // Close only a turn whose start committed to the log. 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.) + // Preserve an established disposal reason; otherwise report the failure. if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition reason = { kind: 'disposed' } } else { @@ -689,19 +427,11 @@ async function runTurn( closeTurn() } - // Durability checkpoint: persistence plugins drain write-behind buffers. - // A failing persistence plugin is reported but doesn't kill the agent. - // Through the store's flush (the carrier owner), never a raw parallel. + // Flush through the store-owned durability checkpoint without killing the driver on failure. 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 closed, so report the failed flush live rather than append outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`) try { @@ -722,13 +452,12 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole return messages.length > 0 } -/** One step: build the request from the boundary snapshot + the step's - * header → compose the session prefix if this instance has none yet → log - * the header event the request owes → stream model → record → execute - * tools. The caller assembles the - * system prompt, fires the `agent/pre-step` seam, snapshots the derivation, - * and opens the step BEFORE calling this, so `boundaryMessages` is exactly - * the surface prefix at step/start and already reflects any compaction. */ +/** + * Run one committed step: transform call config, log the request header, build + * the request from the cached prefix plus the step-boundary snapshot, stream and + * record the response, then execute tools. The caller has already assembled the + * prompt, run `agent/pre-step`, snapshotted history, and opened the step. + */ async function runStep( ctx: Context, events: AgentEventDispatch, @@ -743,40 +472,23 @@ 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 first request from agent options and later requests from the logged header; + // detach and freeze so listeners must return an attributable replacement. 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. + // Listener replacements are recorded in the request header before dispatch. const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig)) if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } - // The session prefix was composed (once per instance) before this step's - // pre-step seam — the caller guarantees it, so the cache is always set here. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call const sessionPrefix = transmission.sessionPrefix! - // The request header (the log's request/header* vocabulary): canonical form, - // recorded before dispatch so the log always explains the request — - // including the session prefix, which no other event carries. + // Record the canonical header, including the otherwise-unlogged prefix, before dispatch. const header = canonicalHeader({ config, ...system ? { system } : {}, @@ -785,11 +497,7 @@ 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. + // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. const request: GenerateOptions = deepFreeze({ model: header.config.model, messages: [...header.messagePrefix ?? [], ...boundaryMessages], @@ -813,26 +521,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 failure finish chunks into the same path as thrown stream 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. + // Preserve usage even when max-token truncation produced no content. 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. + // The finish chunk guarantees non-empty provenance here. session.append( 'assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, @@ -842,20 +540,11 @@ async function runStep( return { hadToolCalls: false, finish: assembler.finish } } - // The step-result waterfall runs BEFORE the session append so the log (the - // source of truth for derived history and replay) records the message that - // tool dispatch actually uses. + // Record the post-waterfall message that tool dispatch uses. let message: Message = assembler.message() message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) - // 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). + // Empty messages exist only to carry usage; omit empty provenance. if (message.content.length > 0 || assembler.usage) { session.append( 'assistant/message', @@ -864,15 +553,9 @@ async function runStep( ) } - // --- Tool execution (sequential; parallel execution is a TODO) --- - // ToolRegistry.execute converts tool failures (including aborts) into - // isError results, so abort is re-checked around every call here. + // Tool execution stays sequential; recheck abort around each normalized result. 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). + // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ @@ -884,12 +567,8 @@ 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): Keep logged history and live presentation aligned; + // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. const result = await ctx.tools.execute({ callId: call.id, name: call.name, @@ -905,23 +584,18 @@ async function runStep( content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. + // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - // Buffer (don't append yet) any post-execute additionalContext for this call. if (result.additionalContext) pendingContext.push(result.additionalContext) - // signal CAN flip during the await above (abort() inside a tool); - // the analyzer can't see through the await boundary. + // The signal may flip while the tool is awaited. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) /* v8 ignore stop */ } - // Append buffered post-execute context AFTER every tool/result, preserving - // tool-call/result adjacency across the whole batch. inject() appends into the - // open turn (a context/message at its chronological position). + // Append buffered context after the complete result batch. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) } @@ -944,13 +618,8 @@ 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 the session log has an unmatched `turn/start`. Agent status is not + * sufficient during pre-start and post-end windows. * @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..c7314c2409 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 request-header bookkeeping for reconstructability. The + * comparison baseline is the header folded from the session log, so a fresh + * loop instance needs no special resume or fork state. * @module dsh-agent-loop/request-log */ @@ -37,22 +32,10 @@ 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 makes the log reproduce this request's header. + * The first request from an instance always records a full `initial` or `resume` + * snapshot. Later requests record nothing when unchanged, a round-tripping + * delta when expressible, or a full `fallback` snapshot otherwise. * * @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 7f65bbc5f3..581b6fa207 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -167,10 +167,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. + // Invalid injected content throws after turn/start. `finally` must still append turn/end and + // flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) }).toThrow(/non-JSON-serializable/) @@ -252,26 +250,20 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // Create a bare ReactLoopAgent and start it through the package-internal - // test seam. Then call its disposer twice — the second call hits the - // early-return branch. + // The internal start seam exposes one idle driver's disposer for repeated invocation. const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) const { agent } = prepared - // Start the loop to get the disposer; the agent waits for messages - // (idle, never-resolving cancel), so it will stay idle. prepared.markPublished() const dispose = prepared.startDriver() - // First dispose const firstDisposal = dispose() expect(agent.status).toBe('disposed') await firstDisposal - // Second dispose — idempotent, no throw await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -366,10 +358,8 @@ 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. + // Queue the internal waiter while running, then dispose the bare driver. Its disposed branch + // must chain the loop's `done` promise rather than resolve before exit. const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -395,11 +385,8 @@ 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 agent-owned state, not an effect-scoped listener that owner disposal would + // remove before the disposed transition. Fiber teardown must still settle it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: ReactLoopAgent @@ -417,10 +404,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. + // Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it + // resolves only after true loop exit. 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..cad830e827 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,12 +1,9 @@ /** - * 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. The suite covers every landing window plus marker + * reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ @@ -95,10 +92,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. + // This waiter cannot rely on a running→idle transition because cancellation + // drops the turn before it runs; the skip path must settle it directly. send(agent, 'q') const idle = agent.whenIdle() agent.cancel('pre-step') @@ -234,12 +229,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 interrupted first composition must not cache its degraded empty value; + // the next prompt recomposes and logs/sends the fresh prefix. 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 +259,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 before a step controller exists, so the + // turn-scoped marker—not step abort—must drop the pending 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 +389,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. + // `agent/status` is synchronous, so cancellation can land after the first + // pre-step check; the second check must drop the now-empty turn. 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 +408,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 +434,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 +445,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..8a5d9ce264 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,8 @@ 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. + // Resume waits for the injected persistence service, so poll until the + // config-created agent appears with its stored history. const ctx2 = new Context() await ctx2.plugin(LlmService) await ctx2.plugin(SessionStore) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts similarity index 90% rename from packages/core/agent-loop/tests/review-fixes.spec.ts rename to packages/core/agent-loop/tests/contract-regressions.spec.ts index 9eb33672bc..ee50261e9d 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -39,7 +39,7 @@ function send(agent: ReactLoopAgent, text: string) { agent.send([{ type: 'text', text }]) } -describe('HIGH: session log records what agent/step-result actually produced', () => { +describe('session log records what agent/step-result actually produced', () => { it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => { const adapter = new MockAdapter([textResponse('original'), textResponse('done')]) const ctx = await harness(adapter) @@ -89,7 +89,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) }) -describe('HIGH: abort during tool execution ends the turn', () => { +describe('abort during tool execution ends the turn', () => { it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step @@ -141,7 +141,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) }) -describe('HIGH: steering from late extension points is never stranded', () => { +describe('steering from late extension points is never stranded', () => { it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -168,21 +168,7 @@ 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. + // Assert the same-turn shape; content alone cannot distinguish re-enqueue. const adapter = new MockAdapter([ textResponse('no tools, would stop'), textResponse('after goal reminder'), @@ -200,12 +186,10 @@ describe('HIGH: steering from late extension points is never stranded', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Same-turn continuation: the steering forced step 2 within turn 1. const events = [...agent.session.events] expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(events.filter(e => e.type === 'step/start')).toHaveLength(2) - // The steered content is recorded as steering (same turn), BEFORE step 2 — - // not as a fresh turn's user/message. This is the mechanism the override uses. + // Same-turn steering precedes the second step. const steeringIdx = events.findIndex(e => e.type === 'steering/message') const step2Idx = events.map(e => e.type).lastIndexOf('step/start') expect(steeringIdx).toBeGreaterThanOrEqual(0) @@ -263,7 +247,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { }) }) -describe('HIGH: plugin exceptions are contained', () => { +describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -318,7 +302,7 @@ describe('HIGH: plugin exceptions are contained', () => { }) }) -describe('MEDIUM: disposed status is part of the agent/status contract', () => { +describe('disposed status is part of the agent/status contract', () => { it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -365,7 +349,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { }) }) -describe('MEDIUM: misc registry and config fixes', () => { +describe('adapter registration, routing, and accepted-input ownership', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -524,7 +508,7 @@ describe('MEDIUM: misc registry and config fixes', () => { }) }) -describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => { +describe('turn numbering continues across seeded sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) @@ -562,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () }) }) -describe('LOW: discriminated SessionEvent narrows without casts', () => { +describe('discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { const session = new Session(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { @@ -580,12 +564,9 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => { }) }) -describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => { +describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { - // 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. + // A finish-error chunk must not produce a completed assistant turn. const errorStream: StreamChunk[] = [ { type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } }, ] @@ -606,7 +587,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete // a standalone error event. const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }) - // Crucially: no assistant/message was logged for the failed step. + // A failed step must not synthesize an assistant message. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) @@ -652,10 +633,7 @@ describe('step boundary publication order', () => { 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.) + // Append commits before observers run. 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 @@ -678,10 +656,7 @@ describe('step boundary publication order', () => { }) describe('turn and step boundary recovery', () => { - // 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. + // The invariants plugin makes an unbalanced log fail the test. async function balancedHarness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -833,9 +808,7 @@ describe('turn and step boundary recovery', () => { }) it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { - // First turn: model stream ends with a finish-error → step error path → - // failTurn emits agent/error, whose listener throws. The turn must still - // close balanced. Second turn proves the loop survived. + // Listener failure cannot interrupt error finalization or the next turn. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) @@ -894,9 +867,7 @@ describe('turn and step boundary recovery', () => { }) it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { - // A pre-step listener requests disposal and then throws before the ordinary - // post-listener disposal check. The outer catch sees disposal already won - // and must preserve reason=disposed rather than rewrite it as a plugin error. + // Disposal remains authoritative when the listener also throws. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent @@ -908,9 +879,6 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/pre-step', () => { if (threw) return threw = true - // Request disposal, then throw in the same synchronous tick: status flips - // to 'disposed' (the disposer aborts the step controller) and the throw - // drives control into the outer catch with isDisposed() already true. void fiber.dispose() throw new Error('boom pre-step during disposal') }) @@ -1001,10 +969,7 @@ describe('turn and step boundary recovery', () => { }) it('a throwing step/end observer cannot interrupt error finalization', async () => { - // A finish-error stream opens a step then fails it, driving finalization - // through closeStep() with the step open. Session contains the observer - // failure after committing step/end, so closeTurn still records the model - // failure and balances the turn. + // Observer failure after step/end commit cannot interrupt turn 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) @@ -1110,11 +1075,7 @@ describe('tool result call identity', () => { 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. + // Injected result content with no chunks must omit empty sourceEventSeqs. const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) @@ -1141,12 +1102,8 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream describe('disposal and cancellation during pre-step assembly', () => { 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. + // Start disposal, then release assembly. Do not await disposal first: it + // waits for the blocked driver to exit. const adapter = new MockAdapter(['hang']) let releaseAssemble!: () => void const blocked = new Promise(r => void (releaseAssemble = r)) @@ -1161,7 +1118,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) - // Blocking listener on the parent context (survives fiber disposal). + // Parent-owned listener survives agent-fiber disposal. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocked return next() @@ -1179,28 +1136,22 @@ describe('disposal and cancellation during pre-step assembly', () => { // Give the loop time to enter the step and reach assemble(). await new Promise(r => setTimeout(r, 50)) - // Start disposal — stop() sets status=disposed synchronously, then the - // disposer's await agent.done hangs because the loop is blocked in the - // waterfall. Do NOT await yet; release the blocker first. + // Release assembly before awaiting disposal because disposal joins the blocked driver. const disposalDone = fiber.dispose() - // Now release the blocked waterfall — the loop unblocks, checks - // isDisposed(), and exits, which resolves agent.done and disposalDone. releaseAssemble() await disposalDone await agent.done unlisten() + // Turn boundaries are durable rows; there is no `agent/*` mirror to assert. const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - // No step was opened, no LLM call was made. expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // The durable turn/end record is the authoritative turn-boundary signal - // (turn boundaries have no agent/* mirror), so this asserts on the log. }) it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { @@ -1257,9 +1208,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) 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. + // Start disposal, then release pre-step; awaiting disposal first would + // deadlock on the blocked driver. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) @@ -1310,8 +1260,7 @@ describe('disposal and cancellation during pre-step assembly', () => { }) it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { - // Block `agent/pre-step`, then cancel() the agent. When the block releases, - // the post-seam check catches cancellation and ends the turn aborted. + // Release pre-step after cancellation to exercise the post-seam check. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 23e5670f85..5deee8e159 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -225,9 +225,7 @@ describe('disposed vs aborted branching', () => { await fiber.dispose() // dispose during hang await agent.done - // The review-fixes test for 'HIGH: disposed status' already covers - // this assertion path. The reason is 'disposed' because isDisposed() is - // checked before the abort signal check in the error path. + // Disposal wins abort classification because the error path checks it first. expect(reasons).toContainEqual({ kind: 'disposed' }) }) }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 79a518ba2b..4bea62abe2 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -64,17 +64,12 @@ describe('Inbox', () => { void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved void inbox.waitForQueued(p1) // second call overwrites wakeup - // Cancel p1 (the latest waiter's cancel) — the wakeup was overwritten - // to p1's resolve, so canceling p1 triggers the finally block which - // clears the wakeup if it matches. + // Cancelling the latest waiter clears the shared callback; enqueue must neither + // wake the stale waiter nor fail on the cleared callback. 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. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } }) - // The overwrite path + finally cleanup are exercised }) it('clears wakeup in finally handler when enqueue resolves', async () => { @@ -88,23 +83,17 @@ describe('Inbox', () => { }) it('finally handler does not clear wakeup when a different waiter overwrote it', async () => { - // First waiter's cancel resolves AFTER a second waiter overwrote wakeup. - // First waiter's finally sees wakeup !== its resolve → does not clear. + // A stale waiter's finally must not clear the replacement waiter. const inbox = new Inbox() const { promise: c1, resolve: r1 } = resolverPair() void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1) void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves - // Resolve c1 (the first cancel). c1.then(resolve1) fires → resolve1() called - // → waiter1's promise resolves → finally: wakeup === resolve1? NO (it's resolve2) - // → wakeup is NOT cleared. r1() await c1 - // Now enqueue: wakeup() calls resolve2 → waiter2 resolves - // But waiter2's cancel never resolves — that's fine, enqueue resolves it. + // The replacement remains registered and is resolved by enqueue. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } }) - // No need to await anything further — enqueue is synchronous wakeup }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index d4ec6312ba..4b37210993 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -117,14 +117,8 @@ 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). + // Prompt rewrites and injected context land before `agent/pre-step`, so a + // compaction listener measures the current surface before the single derive. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -189,9 +183,8 @@ 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. + // Blocking one prompt in a mixed batch must persist its reason even though + // the allowed prompt keeps the turn from ending rejected. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -515,14 +508,13 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { await waitForIdle(ctx, agent) const log = events(agent) - // same turn, two steps + // The continuation stays in the turn, is logged with provenance before step 2, + // and reaches that step's request. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(log.filter(e => e.type === 'step/start')).toHaveLength(2) - // the reason was recorded as steering BEFORE step 2, with its plugin source const steering = log.find(e => e.type === 'steering/message') expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }]) expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' }) - // and reached the next request expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal') }) @@ -618,11 +610,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 71be5b339e..fb686928b1 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). + // The append lands before step/start, yet derive happens afterwards and the + // same step's request must include it. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -557,10 +552,8 @@ 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. + // Before step/start, a pre-step throw reaches the turn catch: no step needs + // closing, the turn records error, and the loop remains available. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -627,16 +620,14 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - // and the reason is recorded in the log's turn/end event + // Assert the durable row, not only the live listener. const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) 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 +709,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. + // Empty content still needs an assistant/message to carry usage; derivation + // skips that host so it does not create a spurious assistant turn. 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 +718,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..dbc43ad985 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,12 +1,7 @@ /** - * 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). + * Deterministic property tests for inbox scheduling: every sent message logs + * once, turn numbers increase, and status follows idle→running→idle/disposed. + * Schedules advance on status events rather than wall-clock sleeps. */ import { describe, expect, it } from 'vitest' @@ -146,10 +141,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 before each send; the last waiter covers the final turn, and + // awaiting an already-settled earlier waiter is harmless. 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..9b8513d16f 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -9,15 +9,13 @@ 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). Mocks establish append-extension; + * this key-gated test establishes a real provider cache hit. */ // 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..c4fe471bd0 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -1,11 +1,9 @@ /** - * 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. Mock-adapter + * requests are the observable, and the final offline rebuild states the full contract end to end. */ 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 93605008ec..3747824231 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -460,10 +460,8 @@ 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. + // Idle injection creates and flushes a one-shot turn. No explicit flush or + // clean disposal follows, so disk presence proves its own checkpoint ran. 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 @@ -485,10 +483,8 @@ 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). + // Turn enclosure keeps idle context out of crash-tail repair, so it must + // survive persistence and resume. 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/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2c478ad1f0..40272ac42b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -934,11 +934,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 disposal must drain real work before registry removal. + // Waiting for turn/start avoids pre-step disposal dropping the queued prompt + // before a turn opens. 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..13e731b7b8 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,10 +1,9 @@ /** - * 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. Registration order is a concurrent loading artifact + * and must not leak downstream. */ import { describe, expect, it } from 'vitest' @@ -93,11 +92,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/README.md b/packages/core/agent/README.md index 656c73b85a..5639f30daf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,28 +8,28 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +`Agent.ctx` owns registrations visible only to that agent. `agentEvents()` couples event subjects to their scope carrier, and `assembleContextFor()` couples the agent and prompt scope. Creation and resume may compose this context through `setup`; the agent remains unpublished and must not be driven until creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) -Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. +The loop plugin registers `AgentFactory`, keeping consumers independent of its concrete package. Each call is traced through the caller's context so the caller owns the resulting transaction and handle. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options)` creates and composes an unpublished session and agent, then atomically enters the registries and starts the loop. A creation-only signal cancels before publication; same-ID contenders arbitrate at entry and losers roll back. +- `ctx.agents.resume(options)` loads a persisted session and follows the same composition and publication boundary. It requires [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (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 `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. +`AgentHandle = { agent, dispose }` is the consumer teardown capability; registry observers receive only the bare agent. Disposal stops and drains the loop and idle-injection flushes before unregistering the agent, detaching its session, and unwinding its scope. Caller and factory unload share that memoized boundary. ### Live events `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. +`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). @@ -73,4 +73,3 @@ The handle every plugin programs against: - **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). -- **`agent/pre-step`'s `fullSystemPrompt`/`sessionPrefix` parameters are a flagged smell** — compaction is their only consumer; a lazy prompt provider or a compaction-specific pressure seam is the marked revisit. diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index cbb641d271..9d024d36be 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,14 +1,7 @@ /** - * Fused scope-carrier dispatch for agent-subject operations, plus the assembly - * context builder. The sanctioned ordinary spelling is - * `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope - * carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as - * the first argument in one move, so a site cannot name a different subject. - * The registry lifecycle pair is the deliberate exception: `enter()` captures - * one stable carrier before commit and `announce()`/detach dispatch through it - * directly, so both lifecycle edges use the same routing identity. The dev - * scoped-dispatch invariant checks both shapes. - * + * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the + * fused dispatcher so subject and scope key cannot diverge; registry lifecycle + * code instead captures one stable carrier for both edges. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -74,9 +67,7 @@ export interface AgentEventDispatch { } /** - * Build the fused dispatcher for `agent`'s events (see the module doc). Cheap - * (one carrier + one small object) — dispatch sites create it per run/turn - * rather than caching it on the agent. + * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. * @returns the fused dispatcher. @@ -121,11 +112,8 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } /** - * The assembly context for one agent's prompt: the typed `agent` DX field and - * the `scope` layer selector, set together (setting `agent` without `scope` - * silently drops the agent's scoped sections/tools from the assembly — the - * dev invariants flag it). THE way the loop (and any custom driver) builds - * its per-step `ctx.systemPrompt.assemble(…)` input. + * Build the prompt assembly context with agent and scope set together, so + * agent-scoped prompt and tool contributions cannot be silently omitted. * @param agent - the agent the assembly is for. * @returns the context to pass to `assemble()`. */ diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 7864f5938c..fc5ad371b4 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -31,59 +31,23 @@ declare module 'cordis' { } } -/** - * Options for programmatically creating an agent through the registry factory - * ({@link AgentRegistry.create}). The caller supplies the live `sessionId` - * (e.g. an ACP-generated id) and optional session metadata (the validated - * `cwd`, fork lineage); the factory creates the session, the agent, and wires - * them together. - */ +/** Options for creating an agent and its caller-named session. */ export interface CreateAgentOptions { /** The agent's id (the registry handle). */ readonly agentId: AgentId /** The live session's id (NOT derived from agentId). */ readonly sessionId: SessionId - /** - * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, and the `seedLength` seed boundary. Mirrors the - * `cwd`/`parentSession`/`seedLength` fields of - * {@link CreateSessionOptions.meta} in dsh-session (the internal-only - * `createdAt`, used when reconstructing a persisted session, is deliberately - * excluded — a factory caller never sets it). This is durable session data, - * so the session boundary validates and snapshots it before asynchronous - * setup begins. - */ + /** Durable session metadata, validated and detached before setup. */ readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } - /** - * Seed events to reconstruct the child session's log from (the fork lineage - * primitive). When present, the factory creates the session with this event - * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the - * in-process FORK subagent backend to seed a child with a balanced - * completed-turn prefix of the parent's log. The prefix MUST be contiguous - * from seq 0, carry only lossless-JSON data, and be balanced (no open - * turn/step, no dangling tool-call), or the session constructor (and the - * dev-mode invariants replay) reject it. The factory passes the raw seed to - * the session's durable validator/snapshot boundary. Absent for a fresh - * (spawn) child. - */ + /** Balanced contiguous event prefix for a forked session. */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */ readonly signal?: AbortSignal /** - * 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**: the callback is trusted same-process - * code and receives the full scoped context, so this is a contract rather - * than a runtime restriction. Drive the agent only after creation resolves. + * Compose the unpublished scoped context before lifecycle announcements. + * Failure rolls back without publishing either id; setup must not drive the agent. */ readonly setup?: (agentCtx: Context) => Promise | void } @@ -101,35 +65,15 @@ export interface ResumeAgentOptions { readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ readonly signal?: AbortSignal - /** - * Resume-time composition of the agent's fresh scoped world. Persistence is - * loaded first; the factory then mints `agentCtx` and awaits setup while the - * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. - */ + /** Compose after persistence load under the same unpublished rollback contract as create. */ readonly setup?: (agentCtx: Context) => Promise | void } /** - * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / - * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers, - * only the holder can tear this agent down. The registered factory provider is - * also a structural owner because the scoped agent depends on that provider's - * service surface; provider unload stops and drains every live handle it made. - * `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 - * exposed only to the consumer owner that created it; the structural provider - * reaches the same teardown internally. Config-created agents (the loop's own - * startup) are owned by the loop fiber and never need a handle. + * Holder-owned agent capability. Disposal stops and drains the loop and idle + * flushes before unregistering the agent, detaching its session, and unwinding + * its scoped context. Provider unload reaches the same quiescence boundary; + * registry observers receive only the bare {@link Agent}. */ export interface AgentHandle { agent: Agent @@ -144,30 +88,16 @@ 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, emits `agent/session-start`, and only - * then starts the loop. The sequence is - * rollback-covered, but notifications delivered before a later listener - * failure remain observable; every agent or session creation announcement - * that began is paired by `agent/disposed` or `session/disposed` during - * rollback. The owner disposes the resolved handle to stop/drain, - * unregister, remove the session, and unwind the scope. - * The registry passes a context carrying the `create()` caller's fiber and - * scope as `ownerCtx`. The implementation attaches the unpublished - * transaction and resulting lifecycle to that owner; it must not infer - * ownership from the factory object's registration context. + * Create and compose under caller ownership, publish and announce session then + * agent, emit session-start, and start the driver. Rollback pairs any creation + * announcement that began. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Load a persisted session and resume an agent on it. Async because it awaits - * both `ctx.sessionPersistence.load` and the optional unpublished setup - * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * Load, compose, publish, announce, and resume an agent under caller ownership. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -209,41 +139,25 @@ 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. + // Agent contexts shadow this plain-context default with an own property. ctx.accessor('agent', { get: () => undefined }) } /** - * Register the agent-creation factory (the loop calls this on construction, - * effect-scoped). A traced Cordis service is canonicalized to its concrete - * target; each create/resume call is then traced through that caller's - * context so ownership follows the caller without stacking proxy layers. - * Throws if a factory is already registered. Returns the disposer; on - * dispose the factory slot is cleared. + * Register the effect-scoped creation factory, rejecting a duplicate. Service + * factories are retraced through each create/resume caller for ownership. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. - * @returns the disposer that clears the factory slot. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * @returns the exact Cordis effect disposer. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - // Avoid stacking two Cordis shadow layers when a caller passes a Service - // already read through a context. Calls are re-traced through their - // actual owner context below. + // Store the concrete service; calls are retraced through their owner. const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory this.factory = { target } 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 disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -255,20 +169,14 @@ export class AgentRegistry extends Service { } /** - * Create and publish a new agent through the registered factory. - * Distinct from {@link register} (which records an already-constructed - * agent): this constructs the agent and its session. Rejects if no factory is - * registered or creation/setup fails. The resolved {@link AgentHandle} lets - * the owner tear down exactly this agent. + * Create and publish an owned agent and session through the active factory. + * Rejects if no factory is registered or creation, setup, or publication fails. * @param options - agent id, session id/seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { const ownerCtx = this.ctx - // Re-trace a Service-backed factory through the accessing context - // explicitly. This preserves AgentLoop's dependency origin while binding - // its effects to ownerCtx; plain factories receive ownerCtx as an explicit - // capability and need no Cordis tracker magic. + // Bind service effects to this caller while preserving factory dependencies. const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver @@ -291,22 +199,10 @@ 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 in the calling effect scope, with scope-filtered + * creation and disposal events. Duplicate ids throw. * @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 for nested teardown ordering. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { @@ -318,22 +214,15 @@ export class AgentRegistry extends Service { } /** - * Insert an already-constructed agent without announcing it. This is the - * advanced ordered-lifecycle primitive used by the async agent factory: it - * first completes setup while the agent is unpublished, then assigns the - * returned detach closure into its pre-installed composite teardown before - * calling {@link announce}. Ordinary callers use {@link register}. + * Insert an unpublished agent for an ordered factory transaction. * @param agent - the prepared, unpublished agent. - * @returns an idempotent closure that removes this exact entry and emits - * `agent/disposed` with listener failures contained. When called from a - * synchronous `agent/created` listener, removal and disposal wait until - * that creation dispatch unwinds. + * @returns an idempotent closure that removes this exact entry and emits the + * paired disposal edge; detachment during creation dispatch is deferred. */ enter(agent: Agent): () => void { const id = agent.id const carrier = scopeTarget(agent, agent) - // This is the authoritative collision boundary. Concurrent create/resume - // operations may both prepare, but only one exact entry can publish. + // Prepared transactions arbitrate identity at this publication boundary. if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) const entry: AgentEntry = { id, @@ -349,11 +238,7 @@ export class AgentRegistry extends Service { const detach = (): void => { if (!entered) return entered = false - // Every callback reached by this creation dispatch must observe the same - // live entry, and disposal must follow creation. A listener may own - // the advanced detach capability, so make that ordering structural: - // visibility and the paired disposal are deferred until announce()'s - // synchronous dispatch has unwound. + // Creation listeners observe one stable entry before paired disposal. if (entry.announcing) { entry.detachRequested = true return diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 7a046dcc82..3aad65a70e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -1,46 +1,6 @@ /** - * 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`. + * Public agent types and live-runtime events. Durable transcript facts and + * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * * @module @deepseek-ai/dsh-agent/types */ @@ -66,38 +26,18 @@ import type { Session } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { - /** - * The agent this assembly is for. The agent loop passes it on every - * per-step assembly (via its `assembleContextFor(agent)` helper, which - * also sets the `scope` field to the same agent — the layer selector - * `dsh-system-prompt` reads); variable providers project per-agent facts - * from it (`options.model` → `{{model}}`, `session.header.cwd` → - * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) - * has no agent — providers must tolerate its absence. Never set `agent` - * without `scope`: the assembly would silently miss the agent's scoped - * sections/tools (the dev invariants flag it). - */ + /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent } } -/** - * Options an agent is created with. The persona is NOT here: the - * dsh-system-prompt config supplies the global default, and a scoped - * `deployment:persona` section may override it for one agent. - * Merge-extensible: plugins declare extra fields via declaration merging. - */ +/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string } -/** - * Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An - * absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content - * must label itself here or its message is recorded as a user prompt (see - * {@link HookContext} on why that label is load-bearing). - */ +/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ export interface SendOptions { source?: MessageSource } @@ -110,54 +50,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 context injected by a listener; `source` prevents plugin text from being labeled as user input. */ 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"). + * Prompt interception result. `allow.content` replaces the prompt and + * `additionalContext` becomes a separate context message. `block` records a + * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. */ 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. - */ +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } @@ -169,47 +77,21 @@ export type ContinuationDecision = */ export type ContinuationStop = Extract -/** - * Why an agent's session lifecycle began, carried by `agent/session-start`. A - * bridge keys its SessionStart hook's matcher on this (Claude Code's - * `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create - * (including a seeded/forked create — a seed is NOT a resume); `resume` = a - * persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are - * driven by those subsystems (compact = `TODO(compaction)`). - */ +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** - * The agent handle — the surface every plugin (UI, hooks, orchestrators) - * programs against. The concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop - * package should depend on the implementation. - */ +/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { readonly id: AgentId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent). - * Registrations through it — tools, prompt sections/variables, event - * listeners, restrictions — are visible to THIS agent only and unwind when - * the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for - * this agent's dispatches (zero self-filtering). Service resolution through - * it flows through the loop plugin's dependency surface — handing out - * `agent.ctx` hands out that capability. Live for exactly the agent's - * lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT. - */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. + * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -221,317 +103,137 @@ 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. + * Append model-facing context without running the model. Idle injection uses + * a one-shot turn and durability checkpoint, while injection during an open + * turn joins it at the current log position. Disposal awaits idle checkpoints; + * flush failures are reported through `agent/error`, not thrown to the caller. */ 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. + * Clear queued and steering work, including work waiting to start, and abort + * the active step. The supplied reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ 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 at idle quiescence; disposal waits for driver exit rather than only the status transition. */ 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. } 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. - * Setup is composition-only by contract; the subsequent - * `agent/session-start` boundary is the first supported place to inject or - * queue startup work. A synchronous listener throw - * vetoes publication and rollback emits the matching disposal edges; - * returned-promise rejection is observed and logged but cannot - * retroactively veto this synchronous boundary. A synchronous listener - * that requests the advanced registry detach does not remove the entry - * immediately: removal and the paired `agent/disposed` edge wait until the - * creation dispatch unwinds, so no later creation listener observes a - * disposal that preceded its own creation callback. + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent was removed from the registry. The concrete AgentLoop lifecycle - * emits this only after its driver and any in-flight turn reach quiescence; - * a custom agent registered through the public registry owns its own driver - * contract, which the registry cannot infer. Ordered teardown may still be - * detaching the session and unwinding scoped registrations when this runs. + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. - * 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 (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A message entered the agent's inbox (queued or steering). Content and the - * resolved source are the detached, deeply-frozen values retained by the - * inbox. `source` has defaults applied and is not the caller's raw options. + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. * @param info - the accepted 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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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): a - * listener cannot veto by returning a decision or throwing. A listener that - * wants to seed context does so via `agent.inject()` (a `context/message` the - * first request sees). A lifecycle owner can still dispose its structural - * ownership edge during this notification; publication rechecks liveness and - * then aborts before the driver starts. + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that 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 durable session events, not agent events. // ---- 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`. - * @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 fullSystemPrompt - the assembled prompt, for measuring token pressure. - * @param sessionPrefix - the instance's frozen session prefix, for the same measurement. - * @param signal - aborts in-flight listener work when the turn is torn down. + * Awaited serial checkpoint for session-surface mutation after prompt + * assembly and before `step/start`; appends land outside the pending step. + * The loop derives history once afterward, so compaction records and + * replacements are included without rewriting an assembled request. The + * prompt and prefix are the exact pressure inputs for that request, and + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param fullSystemPrompt - the assembled prompt. + * @param sessionPrefix - the frozen request prefix. + * @param signal - the turn abort signal. * @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 a compaction-specific seam 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. + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param 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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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. + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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. - * - * 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`. + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request and + * pressure accounting sees the composed prefix. Changing context belongs in + * history; contributors should prepend to `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent whose session prefix is being composed. - * @param prefix - the frozen 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. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. * @mode waterfall */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -542,47 +244,27 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. - * 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 (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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. + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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. + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. - * 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 (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -595,11 +277,7 @@ declare module 'cordis' { * @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`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @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..2761351d10 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' @@ -160,7 +150,7 @@ describe('verify-export-jsdoc export forms', () => { ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)]) }) - it('does not treat a never-exported sibling declarator as surface (review round 2)', () => { + it('does not treat a never-exported sibling declarator as surface', () => { // `export { publicValue }` resolves to the whole variable statement; only // the named declarator is surface — the gate must not demand JSDoc for // the private sibling sharing the statement. @@ -169,7 +159,7 @@ describe('verify-export-jsdoc export forms', () => { ))).toEqual([]) }) - it('unions declarators across multiple export lists over one statement (review round 2)', () => { + it('unions declarators across multiple export lists over one statement', () => { // Two lists each name one declarator of the same undocumented statement: // both are surface (deduplicating on first resolution would drop `b`), // while the never-exported `c` stays out. @@ -182,7 +172,7 @@ describe('verify-export-jsdoc export forms', () => { ]) }) - it('scopes a default-export identifier to its own declarator (review round 2)', () => { + it('scopes a default-export identifier to its own declarator', () => { // `export default` of an identifier reaches the statement through the // same name lookup as an export list; the sibling stays private. expect(collectExportJsdocViolations(make( @@ -325,7 +315,7 @@ export namespace Loose { }) }) -describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { +describe('verify-export-jsdoc fail-closed forms', () => { it('checks the function contract on a non-identifier default export', () => { expect(collectExportJsdocViolations(make( '/** Doubles. */\nexport default (x: number): number => x * 2\n', @@ -418,7 +408,7 @@ describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { }) }) -describe('verify-export-jsdoc heritage refinement (review round 1)', () => { +describe('verify-export-jsdoc heritage refinement', () => { it('requires @param for parameters the base member never names', () => { const violations = collectExportJsdocViolations(make(` /** Seam. */ diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index a1ee0dcf0b..36cc059bc4 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -1,6 +1,6 @@ # 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 registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. ## Public API @@ -15,7 +15,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Design contract -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. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index e229243504..f09844e9ab 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -73,15 +73,11 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { } /** - * Build the routing receiver for a scope-filtered event. Untagged listeners - * remain global; tagged listeners run only when their key matches. A base - * Cordis filter is composed before the scope predicate. - * - * The receiver is deliberately opaque: listener code obtains the real subject - * from event arguments, never from `this`. + * Build an opaque receiver that preserves the base filter, admits untagged + * listeners globally, and admits tagged listeners only for a matching key. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. - * @returns an opaque dispatch carrier. + * @returns a carrier whose subject remains available only through event arguments. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 18f03dd3f3..7e201ab647 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,35 +8,35 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber. -- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives -`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: +Use the split lifecycle only when teardown must be ordered with another resource: -- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds. -- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. +- `prepare(id?, options?)` validates and constructs without publication. +- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement. +- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge. -`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. +`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). ### Live service events -The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). +The store pairs announced creation with disposal, publishes post-commit append notifications with per-listener containment, and provides an awaited durability checkpoint. Exact signatures and scope behavior live in the generated [event catalog](../../../docs/cordis-catalog/events.md); payloads live in the [persistence catalog](../../../docs/persistence-catalog.md). ### Class: `Session` Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs. -- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. -- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). -- `session.surface: SurfaceManager` — the derived surface, lazily built from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal, bumped by every folded `replace`, so an incremental consumer knows when to rebuild. -- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. +- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. +- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback. +- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. +- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. +- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -54,7 +54,7 @@ Durable values need one accepted representation, not a check followed by a secon ### Request-header reconstruction (`request-header.ts`) -The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. +`request/header` and `request/header-delta` make the non-history request envelope reconstructable from the log. `foldRequestHeader()` reconstructs the active header, `diffHeader()` encodes changes, and `applyHeaderDelta()` replays them; unsupported deltas fall back to a full snapshot. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). ### Session event vocabulary (`types.ts`) @@ -76,7 +76,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ## Model Experience diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 548fc5fc3e..28312abb7a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -34,67 +34,41 @@ declare module 'cordis' { interface Events { /** - * A session was created in the store. A synchronous listener throw vetoes - * publication and rollback emits the matching `session/disposed` edge; - * returned-promise rejection is observed and logged but cannot retroactively - * veto this synchronous boundary. A synchronous listener that requests the - * advanced detach does not remove the entry immediately: removal and the - * paired `session/disposed` edge wait until the creation dispatch unwinds. - * 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. + * Creation announcement during session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only sessions entered through that agent's context. * @param session - the session just entered and announced. * @mode emit */ 'session/created'(this: Scoped, session: Session): void /** - * A previously announced session left the store. Emitted exactly once on - * normal detach or publication rollback, and never for a prepared/entered - * session whose `session/created` announcement did not begin. Listener - * failures (including returned-promise rejections) are logged and contained - * per listener so teardown always reaches quiescence. - * Scope-filtered dispatch uses the same owner carrier captured at entry; - * agent-scoped listeners hear only their own session's teardown. + * Emitted once when an announced session leaves the store, including + * publication rollback, but never for an entry whose creation announcement + * did not begin. Listener failures are logged and contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. * @param session - the session that is no longer live in the store. * @mode emit */ 'session/disposed'(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. The log push is the - * commit point; synchronous throws and returned-promise rejections from - * observers are logged and contained per listener, so they cannot make a - * committed append appear to fail or starve later listeners. The exact - * callback list and Cordis internal-dispatch checks resolve before the push; - * callbacks themselves run only after it. - * 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. + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only events from sessions entered through that agent's context. * @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 parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through + * {@link SessionStore.flush}. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ @@ -103,13 +77,9 @@ declare module 'cordis' { } /** - * Renders a `context/message` or `steering/message` event as a tagged - * synthetic user-role message (the system-reminder pattern: zero adapter - * burden, models distinguish it from real user prompts by the envelope). - * - * 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. + * Render injected context as tagged synthetic user-role content, keeping the + * canonical session vocabulary provider-neutral. Adapter-specific exceptions + * belong in the adapter. */ function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] { const open = `<${tag} source=${JSON.stringify(source.kind)}>` diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 2ec36087dd..35874a49e5 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -1,17 +1,4 @@ -/** - * Lossless-JSON validation and snapshot materialization for session 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. Other public boundaries use - * {@link snapshotJsonValue} when they must validate and detach in one pass; - * {@link isJsonValue} remains the non-copying structural predicate. - * - * @module @deepseek-ai/dsh-session/json - */ +/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */ /** * A value that round-trips losslessly through JSON: `null`, a boolean, a finite @@ -25,19 +12,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } /** - * Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass. - * Each array slot or own enumerable string-keyed object value is read exactly - * once, validated, and copied immediately. This is intentionally not - * `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter - * could return plain JSON to the check and an exotic class instance to the - * clone, whose prototype `structuredClone` would erase before a later check. - * - * Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use - * the ordinary `Array.prototype` (subclass instances are not plain JSON - * containers), while null-prototype objects are accepted and normalized to - * ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite - * numbers, unsupported scalar types, and exotic object or array shells return - * `undefined`. A throwing getter is a caller failure and propagates unchanged. + * Validate and detach lossless JSON in one read per property, so a stateful + * getter cannot change between validation and copying. Accepts ordinary arrays, + * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic, + * exotic, negative-zero, and non-finite values. Getter throws propagate. * * @param value - the candidate value to validate and detach. * @returns the detached snapshot, or `undefined` when the value is not @@ -104,28 +82,12 @@ export function snapshotJsonValue(value: T): T | undefined { } /** - * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers - * other than negative zero, booleans, strings, plain arrays, and plain objects - * of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which - * JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON 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. - * - * Scope — this is a structural plain-data predicate, not an invocation of - * `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are - * inspected (`Object.values`). Symbol-keyed and non-enumerable properties are - * omitted from the durable data surface. Custom `toJSON` behavior is not - * executed; boundaries that persist a value first materialize a new plain-data - * record with {@link snapshotJsonValue}. Getters are invoked during this check, - * so callers that need a stable detached value use that one-pass materializer - * instead of checking and then rereading a side-effecting record. + * Test the same lossless JSON boundary as {@link snapshotJsonValue} without + * detaching it. Only own enumerable string properties participate; `toJSON` + * is ignored and getters run, so persistence boundaries use the snapshotter. * @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. - * @returns true when `value` survives a JSON round-trip losslessly. + * @param seen - current recursion path; callers omit it. + * @returns whether `value` survives JSON round-trip losslessly. */ export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { if (value === null) return true diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index cb780da013..efbb3d2004 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -1,37 +1,7 @@ /** - * 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. - * + * Crash-recovery repair for an interrupted session log. It preserves a fully + * written final turn and supplies the missing tool, step, and turn boundaries + * needed to resume with a provider-valid transcript. * @module @deepseek-ai/dsh-session/repair */ @@ -39,36 +9,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. + * Return deterministic synthetic events that close an open tail turn. Unmatched + * calls receive error results first, followed by an open `step/end` and an + * interrupted `turn/end`; sequences continue the log and timestamps reuse the + * last real event. A balanced or empty log returns no events. * - * 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. + // Reset at each turn boundary so earlier calls cannot leak into tail repair. + // Assistant blocks register calls; later tool/call events add provenance seqs. const pendingCalls = new Map() for (const event of events) { switch (event.type) { @@ -97,10 +50,7 @@ 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). + // Add the tool/call seq used as provenance on a synthetic result. { const entry = pendingCalls.get(event.data.callId) if (entry) { @@ -129,10 +79,8 @@ 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). + // Close calls before their step: providers reject dangling assistant calls, + // and Map insertion order preserves their transcript order. 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..dd7b2a758d 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -1,14 +1,7 @@ /** - * 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 over `request/header` snapshots and + * `request/header-delta` events. Writers round-trip each proposed delta and use + * a full snapshot when the encoding cannot represent the change. * @module dsh-session/request-header */ @@ -114,13 +107,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 +129,12 @@ 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. The encoding cannot represent every change, + * including pure tool reordering, so callers must apply and compare the result + * before logging it and fall back to a full snapshot on mismatch. The session + * prefix is replaced whole; an empty array removes it. + * * @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 +171,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/surface.ts b/packages/core/session/src/surface.ts index 263322eccc..ebcd220605 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -23,12 +23,9 @@ const SURFACE_EVENT_TYPES = new Set([ ]) /** - * Whether an event's `type` is surface-eligible (one of the five - * message-producing {@link SurfaceEventType} values). This is the TYPE check - * only — it does NOT require `surfaceOp` to be present. Use it to detect a - * surface-eligible event that is MISSING its mandatory marker (e.g. validating - * a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed - * {@link SurfaceEvent} with `surfaceOp` present. + * Check only whether a type may enter the message surface; it does not require `surfaceOp`. This + * detects eligible seed/load events missing their mandatory marker. Use {@link isSurfaceEvent} to + * narrow a fully formed event whose marker is present. * @param type - the event type string to test. * @returns true when the type is one of the five message-producing types. */ diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 6ea3042bd7..ce9dc639c7 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -1,36 +1,7 @@ /** - * 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 surface. Compaction changes surface + * positions, so safe cuts are derived from tool-call/result content on the + * surface rather than step markers in the append-only log. * @module @deepseek-ai/dsh-session/tool-pairing */ @@ -57,33 +28,14 @@ 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. A region + * is safe to collapse only when the cuts before its first node and after its + * last node both return `true`. * @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; `null` or a seq 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 +45,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 c4838808c1..f4f42062fd 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -14,33 +14,17 @@ 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. + * While the harness is unreleased it is pinned at `0`: no compatibility is + * implied, incompatible logs are rejected, and no migration is provided. A + * monotonic version policy starts with the first tagged release. */ export const SESSION_FORMAT_VERSION = 0 /** - * Immutable session metadata — written once at creation and never rewritten. - * {@link Session} enforces that contract at runtime: it validates and detaches - * the accepted scalar fields, requires this header's id to match the session - * id, and deep-freezes the published record. - * - * Kept SEPARATE from the event log deliberately: format-version, cwd, and - * lineage are storage concerns, not conversation events, so they stay out of - * {@link SessionEventMap} and never reach `deriveMessages()`. Every reference - * system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail - * metadata) writes such a header. + * Immutable validated storage metadata, kept outside the conversation event log. */ export interface SessionHeader { /** @@ -58,13 +42,8 @@ export interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly 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 through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number } @@ -78,17 +57,8 @@ export interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store reads this plain record and each accepted - * field once, then fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string @@ -119,21 +89,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' } @@ -146,26 +102,16 @@ export interface TurnEndReasonMap { */ error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked every prompt before the first step. The zero-step turn still + * records a balanced durable boundary and the veto reason. */ 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. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } @@ -192,15 +138,9 @@ export interface TodoItem { } /** - * The request header: everything about an LLM request besides its derived - * message history — the call configuration plus the rendered system prompt, - * tool schemas, and the session prefix. Logged session state (the - * reconstructability RFC): a - * {@link SessionEventMap} `request/header` snapshot installs one, a - * `request/header-delta` amends it, and folding those events over the log - * (`foldRequestHeader`) reconstructs the header any request was built under. - * Canonical form: an empty system prompt, an empty tool list, and an empty - * prefix are ABSENT fields, matching how requests are built. + * Logged request state outside derived history: call config, system prompt, + * tools, and session prefix. Header snapshots and deltas reconstruct it; + * canonical empty optional fields are absent. */ export interface EpochHeader { /** The conversation's call configuration (model + sampling scalars). */ @@ -262,24 +202,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 merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. */ export interface SessionEventMap { /** @@ -302,14 +228,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()`. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** @@ -346,47 +266,19 @@ 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. + * Whole-list snapshot; the latest write wins on replay. It is log-only UI + * state and never enters derived model history. */ '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 {@link EpochHeader} for the next request, appended inside its step + * before dispatch. It is log-only and anchors subsequent deltas. */ '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}. + * Log-only amendment to the folded {@link EpochHeader}. System and tools use + * their delta codecs; config and prefix replace whole, with an empty prefix + * encoding removal. Writers verify round-trip equality or log a fallback snapshot. */ 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } @@ -434,16 +326,8 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } /** - * Surface metadata passed to {@link Session.append}. - * `surfaceOp` controls how the event enters the surface linked list; - * `sourceEventSeqs` records the seq numbers of events that are provenance - * sources of this one (e.g. the `assistant/chunk` seqs behind an - * `assistant/message`, or the shadowed nodes behind a compaction replacement). - * - * Required for {@link SurfaceEventType} events — every message-producing event - * MUST declare how it enters the surface, because the surface is the sole - * source of derived history. Non-surface event types (`turn/start`, - * `assistant/chunk`, `error`, …) cannot carry surface metadata. + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. */ export interface SurfaceIntent { surfaceOp: SurfaceOp diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 66e99de625..5314c5e88f 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -1,10 +1,7 @@ /** - * Derived-message cache tests: the session projects each surface node exactly - * once (O(new nodes) per call), rebuilds on a surface replacement (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 contract against a scratch oracle: project new nodes + * once, rebuild on surface replacements, return fresh arrays over shared + * frozen messages, and remain value-equal to replay at every step. */ import { describe, expect, it } from 'vitest' @@ -28,7 +25,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 +44,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 +56,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) }) @@ -73,8 +68,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..d599536db5 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. +// Each arbitrary supplies its own surface intent; `build` must not synthesize +// one or the property would fail to exercise malformed fixture choices. 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..e0f6a5bb6e 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -155,11 +155,8 @@ 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 raw tool/call with no assistant-registered pending call has nothing to + // answer; repair still closes the step and turn without synthesizing a result. 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 2204fc9027..2326e7b212 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. + // A widened SessionEventType bypasses the overload's conditional requirement, + // so the runtime guard must still reject the missing surface marker. 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/) @@ -663,10 +657,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. + // A stale prepared object must not replace the live same-id entry; its later + // detach would otherwise remove the wrong session. const ctx = new Context() await ctx.plugin(SessionStore) const stale = ctx.sessions.prepare(SessionId('racy')) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index a1c0db0f59..b276297658 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -115,14 +115,11 @@ describe('SurfaceManager', () => { it('rebuild with replace operation splices out shadowed nodes', () => { const s = surfaceSession() - // seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end - // Surface nodes: seq 1 (user), seq 2 (assistant). - // Replace both with a compaction marker. Both 1 and 2 are valid surface seqs. + // Replace surface seqs 1 (user) and 2 (assistant) with the summary. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }, ) - // Now the surface should have just the compaction node. expect(s.surface.nodes.length).toBe(1) expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker expect(s.surface.nodes[0]!.prev).toBeNull() diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 307b0d8658..eb7b1a6203 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -4,24 +4,9 @@ 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 compaction-cut safety: a cut is balanced only when it + * separates no assistant tool call from its result. Non-step nodes are neutral, + * and replace operations prove surface order—not raw log order—is authoritative. */ const SURFACE = { surfaceOp: 'append' as const } @@ -182,10 +167,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. + // The injected context is pairing-neutral, but both adjacent cuts remain + // unbalanced because the tool call is still open across them. function midStepInjection(): Session { const s = new Session(SessionId('mid-inject')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -236,11 +219,8 @@ 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. + // A replacement checkpoint has a high log seq but sits at the surface head; + // its cuts are balanced regardless of later raw-log neighbors. function checkpointHeadedSession(): Session { const s = new Session(SessionId('checkpoint')) // A closed turn with a tool step → surface [u1, asst(call), result]. @@ -291,10 +271,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/README.md b/packages/core/system-prompt/README.md index c94d62c292..e8975bf17e 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent. +System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default. ## Config @@ -20,7 +20,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Live events -`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). +`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated [event catalog](../../../docs/cordis-catalog/events.md) owns signatures and dispatch contracts. ### Key types diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index b886b4c774..566395e55a 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,13 +1,5 @@ /** - * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, and named prompt variables; `assemble(context)` - * collates them through a waterfall that runs once per step, 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. + * Registry for ordered prompt sections, tool schemas, and prompt variables. * * @module @deepseek-ai/dsh-system-prompt */ @@ -25,58 +17,28 @@ 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. - * - * 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). - * - * The returned assembly is authoritative. This is an expert composition - * seam: a listener that removes or replaces another plugin's protocol - * contribution owns preserving that protocol's invariants. - * @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. + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. * @mode waterfall */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** - * A section, tool provider, or variable provider was registered - * or unregistered (the assembly inputs changed — possibly for one scope - * only). An UNFILTERED registry-subject notification, deliberately not - * scope-filtered dispatch: a global change concerns every agent's next - * assembly, so a scoped listener subscribing here sees every change, not - * just its own scope's. + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. * @mode emit */ 'system-prompt/change'(): void } } -/** - * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR. - * Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent` - * declares the `agent` field, so section text and variable providers can be - * functions of the calling agent. Every field is optional by nature: a bare - * `assemble()` (tests, diagnostics) carries an empty, scope-less context, and - * providers must tolerate absent fields. - */ +/** Merge-extensible context for one prompt assembly. */ export interface AssembleContext { /** - * The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped - * sections/variables/tool-providers registered through this key's context - * join the assembly (shadowing same-named global contributions), and the - * `system-prompt/assemble` waterfall dispatches in this scope. The agent - * loop sets it to the agent (alongside the `agent` DX field — never set - * `agent` without `scope`; the dev invariants flag the mismatch). Absent = - * a scope-less assembly: global layer only, subject-less dispatch. + * Scope whose providers and waterfall listeners participate. When absent, + * only global providers and subject-less listeners participate. */ scope?: ScopeKey } @@ -111,16 +73,7 @@ export interface AssembledSection { text: string } -/** - * What one tool-schema provider contributes to an assembly - * ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction - * visible set for the assembly's scope — exactly what the model may be shown. - * `knownNames` is its PRE-restriction name universe: the set configured names - * (`toolOrder`) are validated against, so a config typo fails loud while a - * restricted-away tool stays a normal, non-erroneous absence. Omitted, - * `knownNames` defaults to the names of `schemas` (right for providers with no - * restriction concept). - */ +/** Tool schemas visible in one assembly and their pre-restriction name set. */ export interface ToolProviderResult { /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] @@ -129,20 +82,8 @@ export interface ToolProviderResult { } /** - * 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. + * Merge-extensible assembled prompt. Sections remain uninterpolated until + * {@link renderPrompt}; tools are already in canonical model-facing order. */ export interface PromptAssembly { sections: AssembledSection[] @@ -156,22 +97,12 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** A complete `{{...}}` reference group at the scan position (validated after). */ const GROUP_AT = /^\{\{([^{}]*)\}\}/ -/** - * The rest entry for {@link Config.toolOrder}: the position where registered - * tools not named in the list are inserted (in lexicographic name order). - * Reserved: collected tool schemas using this name are rejected before - * ordering, so the marker can never collide with a real model-facing tool. - */ +/** Reserved {@link Config.toolOrder} marker for unlisted tools. */ export const TOOL_ORDER_REST = '' /** - * Validate a configured tool-order list's shape at service construction: - * the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names. - * Returns the list (or undefined when unconfigured); throws otherwise, - * failing the service at load — a bad order config must never reach an - * assembly. Whether every listed name matches a registered tool is checked - * at each assembly instead ({@link orderTools}): tool plugins register after - * this service constructs, so the tool set does not exist yet here. + * Validate duplicate names and the required {@link TOOL_ORDER_REST} marker. + * Registered names are checked later because plugins have not loaded yet. */ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined { if (toolOrder === undefined) return undefined @@ -187,20 +118,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. + * Apply configured tool order, inserting unlisted tools lexicographically at + * {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted + * names may be absent. */ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet): ToolSchema[] { const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST) @@ -226,62 +146,25 @@ 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. + * Deployment-wide order-0 persona template. A scoped section named + * `deployment:persona` shadows it; `{{variable}}` references are strict. */ 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. + * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. + * Shape errors fail at load and unknown names fail at assembly; known names + * hidden in one scope may be absent there. Omitted means lexicographic 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. - * - * 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 - * (the caller then sends no system prompt at all). + * Interpolate strict `{{variable}}` references, drop empty sections, and join + * the rest with blank lines. Malformed, unknown, or undefined references throw; + * a lone `{{` without any later `}}` is literal prose, and substituted values + * are not scanned again. + * @param assembly - the assembly whose sections and variables to render. + * @returns the rendered prompt, or `''` when all sections are empty. */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections @@ -298,10 +181,7 @@ function interpolate(section: AssembledSection, variables: Record= 0; open = text.indexOf('{{', last)) { const group = GROUP_AT.exec(text.slice(open)) if (group === null) { - // No complete simple group starts at this `{{`. A `}}` further on means - // a mangled reference (extra or nested braces) — fail loud. With no - // closing `}}` anywhere after, it is ordinary prose (shell, JSON) and - // passes through verbatim. + // A later closing brace makes this malformed; otherwise it is literal prose. if (text.indexOf('}}', open + 2) >= 0) { throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`) } @@ -309,15 +189,12 @@ function interpolate(section: AssembledSection, variables: Record 0 ? known.join(', ') : '(none)'}`) @@ -332,22 +209,11 @@ 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. + // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -363,12 +229,7 @@ export class SystemPrompt extends Service { constructor(ctx: Context, 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. + // Keep harness-owned openers independent of the selected loop plugin. this.section({ name: 'harness:identity', order: -100, @@ -377,30 +238,18 @@ export class SystemPrompt extends Service { this.section({ name: 'deployment:persona', order: 0, - // The schema already defaulted an omitted persona to ''; the ?? only - // narrows the optional-input TYPE, it never supplies a different value. + // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', }) } /** - * 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`). The readonly typed contribution is borrowed until - * disposal; only the semantic - * finite-order rule is checked at runtime. 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. - * @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 - * yield it directly — exact identity nests the teardown in order. + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. */ section(section: PromptSection): () => void { if (!Number.isFinite(section.order)) { @@ -421,10 +270,7 @@ export class SystemPrompt extends Service { : `prompt section "${section.name}" is already registered in this scope`) } layer.push(section) - // Yield the rollback BEFORE emitting `system-prompt/change`: a generator - // effect collects each yielded disposer before the next step runs, so a - // throwing change listener removes the section instead of leaking it into - // every future assembly. + // Install rollback before notifying listeners that may throw. yield () => { const index = layer.indexOf(section) /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ @@ -434,31 +280,17 @@ 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). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity 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`. - * @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 - * yield it directly — exact identity nests the teardown in order. + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. + * @returns the exact Cordis effect disposer. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { const scope = scopeOf(this.ctx) @@ -471,7 +303,7 @@ export class SystemPrompt extends Service { return created })() layer.push(provider) - // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + // Install rollback before notifying listeners that may throw. yield () => { const index = layer.indexOf(provider) /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */ @@ -481,33 +313,18 @@ 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). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity 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. - * @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 - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { if (!VARIABLE_NAME.test(name)) { @@ -528,7 +345,7 @@ export class SystemPrompt extends Service { : `prompt variable "${name}" is already registered in this scope`) } layer.set(name, provider) - // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + // Install rollback before notifying listeners that may throw. yield () => { layer.delete(name) if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope) @@ -536,47 +353,22 @@ 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). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * 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 detached because assembly waterfalls may mutate them. - * Runs through the `system-prompt/assemble` waterfall, giving listeners the - * opportunity to mutate or replace the assembly; the returned value is the - * authoritative model-visible composition. Like the sections' `order` - * sort, tool canonicalization happens on the initial assembly; 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}). - * @returns the assembly after the waterfall has run. + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. */ - // 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). + // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { const scope = context.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 globals. const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) @@ -585,21 +377,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 globals 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. + // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.toolProviders, ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5b8f472017..0f7974e0f9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,16 +11,16 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry contributes the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); `both` contributes the visible native definitions and both infrastructure pieces. Restrictions cannot remove `run_code`, and registering, shadowing, or explicitly filtering that reserved name fails loudly. An expert `system-prompt/assemble` listener may replace any prompt or schema contribution; its returned assembly is authoritative, so the listener owns preserving Code Mode when the protocol should remain active. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. -- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace. +- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. ### Injected services @@ -45,7 +45,10 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Extension points - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. -- `tools/pre-execute` is the reorderable allow/deny/ask gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch, while an `ask` resolves through the approval seam and dispatches only after a grant. Either non-grant path yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper. +- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. +- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. +- `tools/post-execute` may replace content, block with feedback, or attach context; `tools/result` observes the immutable final outcome. +- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. ### Typed tool parameter schemas @@ -77,66 +80,28 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. +A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. -`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model. +Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. ### Structured-output schema subset -A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it. - -The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws). +`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. ### Tool-owned UI presentation -A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`): +Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: -- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of: - - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`). - - `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card. - - `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`. -- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of: - - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. - - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). - - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff). +- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. -Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. - -```ts -import { defineTool } from '@deepseek-ai/dsh-tools' - -const bash = defineTool({ - name: 'bash', - description: 'Run a shell command.', - parameters: { - command: { type: 'string', required: true, description: 'The command to run.' }, - description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' }, - }, - async execute(args) { - return [{ type: 'text', text: `ran: ${args.command}` }] - }, - // A terminal card: the command is the title, the description renders above it. - presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }), - // A terminal result: the raw output + exit; the bridge derives the fenced fallback. - presentResult: (_args, result) => { - const block = result.content.length === 1 ? result.content[0] : undefined - if (block === undefined || block.type !== 'text') return undefined - return { card: 'terminal', output: block.text } - }, -}) -``` +Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale. ### Code Mode -Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. - -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency. -- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. - -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. When no assembly listener changes the registry's prompt or schema contributions, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run `additionalContext` is omitted to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. ## Model Experience diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 323fbeed2b..8156e21bb5 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -1,15 +1,7 @@ /** - * 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 `run_code` transport. Programs call the registry's agent-visible + * tools through nested, sequential executions; each sub-dispatch is logged for + * reconstruction, while only the outer curated result enters model history. * @module @deepseek-ai/dsh-tools/src/code-mode */ @@ -24,16 +16,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 +76,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) { @@ -139,7 +121,7 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, - * executed through the dispatch bridge described in the module doc. The + * executed through the dispatch bridge described above. The * registry reserves it as presentation infrastructure under non-native modes, * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, @@ -172,11 +154,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 +191,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 +244,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 +265,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 a44d054c89..d0c75e8035 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,18 +1,6 @@ /** - * 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, model presentation modes, and pre/guard/around/post/result + * execution pipeline. * @module @deepseek-ai/dsh-tools */ @@ -83,79 +71,34 @@ 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. - * 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). + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @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 tool and scope the pipeline - * accepted. (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 for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @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). + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @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: Readonly, next: () => Promise): Promise /** - * Synchronous 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. + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. * @param exec - the execution object that traversed the pipeline. * @param result - a deep-frozen snapshot of the final returned result. * @mode emit @@ -174,18 +117,10 @@ declare module 'cordis' { } } -// TODO(review): revisit these shapes when concurrency metadata becomes useful +// TODO(concurrency): revisit these shapes when concurrency metadata becomes useful // (for example, a read-only hint that would permit safe parallel execution). -/** - * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the - * common case (model-facing content only); the object form additionally attaches - * a tool-private `meta` presentation payload that the registry threads onto the - * `tool/result` session event and hands back to the tool's `presentResult`. - * `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape), - * and MUST be JSON-serializable: it persists on the durable log (the session - * enforces this at `append`), so replay reproduces the card. - */ +/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } /** A registered tool: its schema plus the execution function. */ @@ -236,12 +171,7 @@ export interface ToolResult { declare const toolExecutionTokenBrand: unique symbol -/** - * Opaque identity for one trip through the tool pipeline. Nested - * transports carry the enclosing execution's token instead of its live object, - * so observe-only result listeners can correlate calls without gaining a - * mutation path into an outer around-dispatch wrapper. - */ +/** Opaque call identity that permits correlation without exposing mutable execution state. */ export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } /** @@ -307,14 +237,8 @@ export interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing context a `tools/post-execute` listener attached for the - * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part - * of this call's `content` — `content`/`feedback` shape the tool RESULT, but - * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * Model-facing context for the next request, separate from this tool result. + * The loop buffers it until all step results are logged, preserving pairing. */ additionalContext?: HookContext /** @@ -327,19 +251,10 @@ export interface ToolExecutionResult { } /** - * 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-dispatch decision. `allow` runs the call; `deny` materializes an error; + * `ask` runs only after an approval service returns `allowed-once` and otherwise + * denies. Input rewriting is excluded because arguments are already logged and + * presented. */ export type PreToolDecision = | { kind: 'allow' } @@ -347,16 +262,8 @@ export type PreToolDecision = | { 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-dispatch decision: accept or replace content, attach context for the next + * request, or block by turning corrective feedback into an error result. */ export type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } @@ -399,35 +306,17 @@ 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. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. + * Code modes require a TypeScript runtime and fail prompt assembly when it is + * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. */ 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 merged after the global filter (which is what keeps e.g. a - * structured-output capture tool alive under an allow-list). The readonly - * filter values compile to private sets at registration, but resolution uses the live global registry: - * a later global name passes a deny-only filter unless explicitly denied and - * fails an allow-list unless explicitly allowed. 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. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -468,26 +357,8 @@ 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 private visibility resolver feeds the registry's prompt - * contribution, {@link get}, and {@link execute} — and, under a non-native - * mode, the SDK section and `run_code`'s bindings — so those registry-owned - * presentation and dispatch paths agree. An expert `system-prompt/assemble` - * listener may deliberately replace the final wire composition and owns any - * resulting divergence. + * Tool registry and execution pipeline. Scoped registrations shadow globals; + * one visibility resolver feeds presentation, lookup, and dispatch. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] @@ -525,13 +396,7 @@ 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 from the calling scope's visible tools in stable order. text: (context) => { this.requireCodeRuntime() return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME)) @@ -541,22 +406,8 @@ export class ToolRegistry extends Service { } /** - * 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 known-name universe for `toolOrder` validation. + * Build one scope's wire schemas and names for prompt-order validation. + * Restrictions do not make known tools invalid, but a mode collapse does. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) @@ -594,23 +445,10 @@ 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. Definitions are trusted typed - * same-process contributions; JSON materialization happens when the schema or - * result reaches its model/log boundary. Emits `tools/change` on - * register/unregister. - * @param definition - the tool's schema plus its execute (and optional - * presentation) functions. - * @returns the disposer that unregisters the tool. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { const scope = scopeOf(this.ctx) @@ -631,52 +469,26 @@ export class ToolRegistry extends Service { : `tool "${name}" is already registered in this scope`) } layer.set(name, definition) - // 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. + // Install rollback before notifying listeners. yield () => { layer.delete(name) - // An emptied scope layer is dropped so a disposed scope leaves no - // residue keyed by its (dead) key. + // Drop empty scope layers. if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) this.ctx.emit('tools/change') } 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). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity 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 CURRENT global end-capability - * universe and throws on an unknown or scope-local name (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 readonly arrays are compiled to - * private sets at registration. Resolution still uses the live global registry, so a later - * global name passes a deny-only filter unless named and fails an allow-list - * unless named. Multiple restrictions compose by intersection. Scoped - * registrations are merged after restrictions and therefore remain visible. - * Disposed with the calling fiber (revocable independently); emits - * `tools/change`. + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. * @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 - * yield it directly — exact identity nests the teardown in order. + * @returns the exact disposer that lifts this restriction. */ restrict(filter: ToolRestriction): () => void { const scope = scopeOf(this.ctx) @@ -714,12 +526,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). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -841,15 +648,8 @@ export class ToolRegistry extends Service { } /** - * The model-facing schemas of everything `scope` can see — exactly the - * fields (`name`, `description`, `parameters`) this registry contributes to - * system-prompt assembly before its expert transformation waterfall. - * 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. + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. * @param scope - the viewing scope (the agent); omitted = the global view. * @returns one deep-cloned schema per visible tool. */ @@ -868,27 +668,13 @@ 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 is - * materialized as a detached lossless-JSON snapshot; an invalid outcome is - * normalized to an error. + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. - * @returns the materialized final result after every waterfall; listener and - * tool failures resolve as `isError` results rather than rejections. + * @returns the materialized final result. */ async execute(exec: ToolExecutionInput): Promise { const token = createExecutionToken() diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 4c1036773b..e1a0dc43a6 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,31 +1,9 @@ /** - * 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 for subagents and workflows. It supports + * one scalar `type`; object `properties`/`required`/boolean + * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued + * annotations. Unsupported or misplaced keywords reject rather than being + * accepted without enforcement, and structured-output roots must be objects. * @module dsh-tools/json-schema */ diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index b99fa08ebd..e2fd2cf9ec 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 */ @@ -56,14 +43,8 @@ export interface FileDiff { } /** - * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a - * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged - * discriminated union: a tool declares its render INTENT once and a UI bridge - * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — - * the tool owns its presentation, so a UI never special-cases tool names. - * - * Returned by `ToolDefinition.presentCall`. See the render-intent-union - * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + * Provider-neutral pending-call presentation. Tools declare one tagged intent; + * UI bridges map it without special-casing tool names. */ export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView @@ -186,16 +167,10 @@ 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}. Because a completed UI update replaces the + * pending card content, mutation tools return this even when it repeats the + * call-time diff; otherwise raw result text would replace the diff. */ export interface DiffResultView { card: 'diff' diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index d3a2d32e18..9e671f6c73 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,23 +1,4 @@ -/** - * 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 - */ +/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' @@ -328,39 +309,13 @@ 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. - * + * Define a first-party tool whose execution and presentation arguments are + * inferred from its per-property schema. Raw JSON-Schema definitions remain + * valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar. * @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 - * raw args first (throwing {@link ToolArgsError} on mismatch, which the - * registry turns into an isError result), and its presenters validate softly - * (returning undefined on mismatch, since replay may feed them older-schema - * args). + * @returns a registry-ready definition with strict execution validation and + * soft presenter validation for replay compatibility. */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 63ebd0f888..bd8c08ed62 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. + // Collapse prose to stable one-line docs and escape comment closers so a + // schema description cannot terminate generated JSDoc. 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 f9d84aadbf..fe5ba5402a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -350,10 +350,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. + // Freeze the nested observer's parent correlation. If that were the live + // outer execution object, the timeout-style wrapper could not restore it. ctx.on('tools/execute', async (exec, next) => { if (exec.name !== RUN_CODE_NAME) return next() const previous = exec.signal @@ -577,11 +575,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 a2dff84287..59bd34e7ab 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -682,16 +682,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). + // Registry methods return the exact Cordis effect disposer so a composite yield places + // unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async + // probe yields during earlier teardown and would then observe the tool already removed. const ctx = await setup() const order: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { @@ -978,20 +971,18 @@ describe('schema DSL edge cases', () => { port: { type: 'number' }, }, }) - // no 'required' key in the nested object because nothing is required const config = jsonSchema.properties['config'] as Record expect('required' in config).toBe(false) }) }) -describe('schema DSL regressions (Codex review round 2)', () => { +describe('schema DSL optional and nested contracts', () => { it('InferArgs makes non-required keys genuinely optional (omittable)', () => { type Args = InferArgs<{ path: { type: 'string'; required: true } limit: { type: 'number' } }> expectTypeOf().toEqualTypeOf<{ path: string; limit?: number }>() - // omitting the optional key is assignable — the actual regression const omitted: Args = { path: '/tmp' } expect(omitted.limit).toBeUndefined() }) diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 697a1ddb71..7766a4a954 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) -// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the +// ctx.fs uses the local backend; load @deepseek-ai/dsh-fs-policy for the // freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` @@ -23,7 +23,7 @@ The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `sr ## Model Experience -Indirectly, through `dsh-tool-fs`, which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages under `Error: ` into capped retained tool results while versions, atomic-write mechanics, and directory metadata remain internal. +Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal. ## Known Limitations and Deferred Work diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index f9af5375d2..fba2a240eb 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 mechanics. This provider layer returns validated UTF-8 text, + * streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes + * stage an exclusive owner-only file in a private sibling directory and atomically rename it. * @module @deepseek-ai/dsh-fs-local/fsio */ @@ -121,14 +108,9 @@ 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. For a missing target, + * realpath the nearest existing ancestor and append the missing suffix, preserving identity + * across symlinked ancestors before and after creation. * @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 +345,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 +470,8 @@ 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 overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still + * succeeds and presentation falls back to a whole-file diff. * @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. Empty or missing search text throws + * `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true. * @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..d3e049199c 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,15 +1,6 @@ /** - * 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`. - * + * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases + * share stale guards, and writes through a symlink update its target without replacing the link. * @module @deepseek-ai/dsh-fs-local */ @@ -156,18 +147,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 +174,9 @@ 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. + // Missing targets use the same stale code on guarded and unconditional edit paths. 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/README.md b/packages/fs/fs-policy/README.md index 5277f7a06e..bd85be88cf 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -37,7 +37,7 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek ## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a `WeakMap>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "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 `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. +Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. ## Single-slot, first-wins @@ -51,7 +51,7 @@ Because the plugin influences the world only through events, removing it does no ### Filesystem tool outcome -**What the model sees**: This plugin adds no prompt or schema. Through `dsh-tool-fs`, an edit without a prior read becomes exactly `Error: edit requires reading "" first` with code `FS_NOT_OBSERVED`; guarded mutations whose observed version is stale receive the backend's exact `Error: cannot "": file changed since it was read` with code `FS_STALE_VERSION`. Observation state itself is never shown. +**What the model sees**: This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. **Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload. diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index 4d5c7964b7..c38e7494e0 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,45 +1,8 @@ /** - * 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`. - * + * Event-only filesystem observation policy; it registers no service. A weak owner/target map + * records every successful read or mutation, single-slot intent listeners supply that version, + * and the provider performs the atomic freshness check. Without this plugin, tools retain the + * bare provider's unconditional mutation behavior. See the package README for composition rules. * @module @deepseek-ai/dsh-fs-policy */ @@ -145,15 +108,11 @@ 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 must remain synchronous and non-throwing: the mutation already succeeded, and + // emit does not await promises. WeakMap.set satisfies that contract. 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..85c6ae0b52 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,14 +1,4 @@ -/** - * 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. - */ +/** Event-level policy tests; no filesystem provider is needed because the plugin performs no I/O. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 1e0ab03b85..1c70226aff 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. - * + * Filesystem text-storage provider seam. Backends own stable target identity, + * text decoding, binary rejection, and atomic mutations. Read windows and + * observed-state policy stay in consumer and policy plugins; `editText` remains + * here so version check, literal match, and rewrite share one critical section. * @module @deepseek-ai/dsh-fs */ @@ -92,44 +41,25 @@ 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 for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. * @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 for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. * @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 a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. * @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 +70,10 @@ 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. Targets must preserve identity across aliases; + * reads expose regular UTF-8 text or typed errors, listings are stable and + * content-free, and mutations are atomic. Optional guards add stale protection + * without changing the unguarded provider contract. */ 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`. @@ -230,10 +128,8 @@ export abstract class FileSystem extends Service { abstract listDir(target: FsTarget, signal?: AbortSignal): Promise /** - * Create or fully replace a UTF-8 text file atomically. `expected` is the - * create-vs-replace decision and stale guard when supplied; OMITTING it is an - * unconditional create-or-overwrite (the bare provider — no version guard, no - * read-first requirement). Atomic either way. + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. @@ -243,11 +139,9 @@ 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`. + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. * @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..73c8ff4837 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,10 @@ 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). + * Guarded write intent. `createIfAbsent` rejects an existing target with + * `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with + * `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional + * create-or-overwrite, not a third union arm. */ 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..cf5b808662 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -1,14 +1,6 @@ /** - * 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 presentation for write and edit. Storage returns before/after + * text; this model-facing layer derives one three-line-context card per applied hunk. * @module @deepseek-ai/dsh-tool-fs/src/diff */ @@ -29,18 +21,12 @@ 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. Pure insertions use `oldText: null`, + * patch-only no-newline markers are omitted, and scattered replacements remain separate hunks. * - * 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 +67,10 @@ 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. Malformed metadata + * returns `undefined` so presentation can fall back instead of throwing during replay. + * @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..c347d7d958 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,14 +1,7 @@ /** - * 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`. - * + * Model-facing literal edit, unique-match by default. It obtains an optional guard from the + * single intent slot, calls `ctx.fs.editText` without a separate stat, then records the observed + * version; no policy means an unconditional atomic edit. * @module @deepseek-ai/dsh-tool-fs/src/edit */ @@ -96,22 +89,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). + // An edit necessarily changes content, so result metadata carries at least one applied hunk. 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', @@ -120,10 +107,8 @@ export function applyEditTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, - // 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. + // Applied metadata replaces the call-time snippet; errors or malformed replay metadata use + // the generic result 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..16d352f1b7 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,24 +1,7 @@ /** - * 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. - * + * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, + * read windows, formatting, and observation events, never a concrete provider. An optional + * event policy supplies mutation guards; without one the tools use unconditional provider calls. * @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..943ff98f61 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. - * + * Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and + * model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant + * line cannot grow memory without bound. * @module @deepseek-ai/dsh-tool-fs/read-render */ @@ -113,12 +102,8 @@ 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. + * Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing + * `FS_NOT_FOUND` when the requested offset is 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..77bb76eb20 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. - * + * Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version, + * streams large or size-unknown files, renders a bounded window, then emits the observation. * @module @deepseek-ai/dsh-tool-fs/src/read */ @@ -98,9 +90,7 @@ 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). + // A concurrent write can only make a later guarded mutation fail stale and require reread. 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 +118,10 @@ 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). The window reflects raw args, so an omitted limit keeps + // the title bare instead of smuggling config into this pure presenter. 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..2f53d630ce 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -1,18 +1,10 @@ /** - * 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. - * + * Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading + * `process.cwd()` at the tool seam. * @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..86fe186e3b 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,13 +1,7 @@ /** - * 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. - * + * Model-facing full-file write. It obtains an optional intent from the single policy slot, calls + * `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an + * unconditional atomic create-or-overwrite. * @module @deepseek-ai/dsh-tool-fs/src/write */ @@ -75,20 +69,17 @@ 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). + // Overwrites carry applied hunks. Creates have no prior text, so result presentation uses + // the args-derived whole-file diff instead. 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 +88,10 @@ 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). Overwrites use + // applied metadata; creates and identical overwrites use the replay-safe args fallback. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 61c163c28b..0487962922 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -11,15 +11,9 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' /** - * Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the - * DeepSeek adapter + the real fs provider + the read-before-write/edit policy + - * the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so - * importing it never re-registers another file's tests. - * - * `fsCwd` is the local backend's default base; a per-session cwd (set via a - * session header) overrides it, but this harness creates agents without a - * session cwd, so the provider default IS the workspace. `persona` is the - * deployment persona (the system-prompt plugin's per-context config). + * Build the real fs-tool stack for with-key e2e tests. Agents have no session + * cwd, so `fsCwd` is their workspace; `persona` configures the deployment prompt. + * This helper lives outside the e2e glob so imports do not register tests. */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c0973197eb..26a81343ff 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,16 +1,8 @@ /** - * 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. + * End-to-end tool-registry tests against the real local backend. The policy deployment verifies + * observed-state and guarded mutation; the bare deployment proves unconditional tools have no + * policy-service dependency. Assertions read files back byte-for-byte rather than trusting tool + * messages. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,9 +20,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`). +// No header cwd: sessionCwd returns undefined and the provider's configured test dir applies. const session = { header: {} } let callCounter = 0 @@ -290,13 +280,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 +385,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..1fdb75637a 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,11 +1,6 @@ /** - * 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 over a fake provider and the real policy collaborator: schemas, + * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. */ import { describe, expect, it, vi } from 'vitest' @@ -409,9 +404,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 +446,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/README.md b/packages/guard/repeat-tool-guard/README.md index 723afbf7ca..843611c2a7 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and folds its reminder onto the downstream decision (both variants — a blocked call still gets the nudge); when a downstream listener attached its own `additionalContext`, the fold concatenates content and carries the guard's `source` (a `HookContext` holds one `MessageSource`; `source.kind` is what framing depends on). +Reminders use source-attributed `additionalContext`, preserving the tool's original result. The loop records them after the step's results as reconstructable `context/message` events. The guard always delegates and folds its reminder onto downstream context, including blocked calls. ## Testing diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 6a5662d693..ca4e6b5c0e 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,37 +1,9 @@ /** - * 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). - * + * Advisory repeat-call loop breaker. It never registers, blocks, or rewrites a tool; configured + * consecutive canonical calls add source-attributed context after downstream post-policy. The + * loop logs that model-visible reminder as reconstructable context. Counters are per agent and + * in-memory, so one agent cannot trip another and resumed sessions start fresh. Named exports + * preserve loader metadata. See the package README for chain semantics and thresholds. * @module @deepseek-ai/dsh-repeat-tool-guard */ diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index e43aa85f5a..96a423fea9 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). - **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. -- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. - **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence). diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts index 59de886e5e..273e86f312 100644 --- a/packages/hooks/hook-protocol/src/codec.ts +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -1,20 +1,7 @@ /** - * 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`). - * + * Decode hook process outcomes for both dialects. Exit 0 may carry structured + * JSON or plain stdout; exit 2 blocks with stderr as the reason; every other + * exit is a non-blocking error. Bridges decide which recognized fields apply. * @module @deepseek-ai/dsh-hook-protocol/codec */ @@ -58,49 +45,30 @@ 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 a dialect-neutral hook outcome. This function is + * total: malformed JSON remains plain stdout. When `expectedEventName` is set, + * a missing or different `hookSpecificOutput.hookEventName` discards only its + * event-scoped fields; top-level fields and the claimed discriminator remain. + * Omitting the guard applies the block as-is. + * @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 - firing event used to guard hook-specific fields; omit to disable the guard. * @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. @@ -150,13 +118,7 @@ function applyStructured(output: HookOutput, parsed: Record, ex // Always surface the discriminator (for the log/diagnostics), even on a // 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). + // A missing or mismatched discriminator cannot affect the firing event. 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..8fa21cadf2 100644 --- a/packages/hooks/hook-protocol/src/detached.ts +++ b/packages/hooks/hook-protocol/src/detached.ts @@ -1,16 +1,7 @@ /** - * 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 emit-shaped hook runs that no seam awaits. Bridges + * track the run plus its continuation, pass the tracker signal into execution, + * and drain on disposal so no process or late callback outlives the fiber. * @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..025250cdac 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 durable, log-only hook events. They carry no surface + * intent and must remain turn-enclosed and invoked/result paired. Mid-turn hook + * points satisfy that boundary; SessionStart records injected context instead + * and does not append `hook/*` outside a turn. * @module @deepseek-ai/dsh-hook-protocol/events */ @@ -92,12 +83,9 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): } /** - * Append a `hook/result` outcome event to `session` (pairs with a prior - * `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's - * parsed decision, else `'stop'` when it asked to halt (`continue: false`), - * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to - * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode` - * is omitted when the hook never ran. + * Append the durable result paired with `hook/invoked`. The recorded decision + * is the parsed decision, then `stop` for `continue:false`, else `pass`; stderr + * is trimmed and capped, and an absent process exit stays omitted. * @param session - the session whose open turn records the event. * @param record - the outcome to record: the decoded output plus the summary cap and duration. */ diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index df8908490d..e342665057 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. - * + * Shared, non-plugin hook protocol library: matching, command execution and + * decoding, restrictive outcome merging, durable event helpers, and detached + * run quiescence. Claude Code and Codex bridges own their distinct payloads, + * environment rules, matcher mode, and typed seam mappings. * @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..036954a59c 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,20 +1,8 @@ /** - * 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)`). - * + * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ + * pipe patterns as literal alternatives and other patterns as regex; Codex + * treats every non-empty pattern as an unanchored regex. Missing, empty, and + * `*` match all; invalid regexes silently match nothing. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -29,15 +17,14 @@ function isMatchAll(matcher: string | undefined): boolean { 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). + * Whether `matcher` selects `query` under the given dialect. Claude literal + * patterns exact-match pipe-separated alternatives; all other patterns are + * unanchored regexes. Invalid regexes return `false` rather than throwing. * @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..c121135501 100644 --- a/packages/hooks/hook-protocol/src/merge.ts +++ b/packages/hooks/hook-protocol/src/merge.ts @@ -1,23 +1,8 @@ /** - * 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. - * + * Merge matched hooks into one most-restrictive outcome. Permission precedence + * is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons + * for the winning rank are joined; and context and system messages accumulate + * in hook order. * @module @deepseek-ai/dsh-hook-protocol/merge */ @@ -76,10 +61,7 @@ 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. + // Keep reasons per rank so only objections explaining the winning decision surface. 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..fefb6936c9 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. - * + * Execute command hooks through `ctx.bash`, using its credential scrub, + * process-group cancellation, and timeout machinery. The bridge supplies the + * trusted stdin payload and dialect environment, then this module decodes the + * captured outcome. * @module @deepseek-ai/dsh-hook-protocol/runner */ @@ -61,15 +54,10 @@ 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` with serialized stdin and decode its outcome. A hook-specific + * timeout in seconds overrides the default; trusted environment entries merge + * after the executor scrub. Infrastructure rejection becomes an outcome with + * no exit code, so this function never throws or crashes the calling turn. * @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..7458419a01 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 and log-only events shared by the Claude Code and + * Codex hook bridges. Payload construction, matching differences, environment, + * and seam-specific decision mapping remain owned by each bridge. * @module @deepseek-ai/dsh-hook-protocol/types */ @@ -32,15 +24,9 @@ declare module '@deepseek-ai/dsh-session' { 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 outcome paired to `hook/invoked` by `handlerId`. Decision is the + * parsed permission result, `stop` for `continue:false`, or `pass`; exit code + * may be absent, stderr is bounded, and duration is wall-clock runtime. */ 'hook/result': { turn: number @@ -134,14 +120,8 @@ export interface HookOutput { /** The reason/explanation accompanying {@link decision}. */ reason?: string /** - * The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted - * a `hookSpecificOutput` block. The reference schemas key that block by event, - * so a block whose `hookEventName` names a DIFFERENT event than the one firing - * is malformed: {@link parseHookOutput} DISCARDS its event-scoped fields when - * given the firing event's `expectedEventName` (a hook claiming `PreToolUse` - * output on a `Stop` event does not affect the `Stop`). This field is still - * surfaced even on a mismatch — the record shows what the block claimed. Absent - * when the hook emitted no `hookSpecificOutput`. + * Event discriminator claimed by `hookSpecificOutput`. On mismatch, + * {@link parseHookOutput} preserves this value but discards event-scoped fields. */ hookEventName?: string /** Extra context to inject for the next model request (CC `additionalContext`). */ diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 7d348cdc0c..3797d4e56f 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -1,13 +1,8 @@ /** - * Parse the bridge-supported subset of a Claude Code hook config file into the - * shared {@link MatcherGroup} shape. - * - * 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 - * (`http`/`mcp_tool`/`prompt`/`agent`) are parsed but skipped with a warning. - * The `command` string undergoes - * `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal. - * + * Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s. + * Only command hooks run; other hook types are returned as skipped so the + * bridge can warn. Plugin-root and project-directory substitutions are applied + * to commands at parse time. * @module @deepseek-ai/dsh-hooks-claude/config */ @@ -57,13 +52,14 @@ 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 either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are + * ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions + * are applied to 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). * @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 a24c3e3d78..08a2d26c9d 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -1,23 +1,11 @@ /** - * `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. This bridge is a - * compatibility path for the mapped CC command-hook subset; 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 - * shell-form `type: 'command'` hooks run. `updatedInput` (tool-input rewrite) is - * logged + warned, not honored (deferred — see the interception-seams RFC). - * + * Bridge for unmodified Claude Code command hooks on harness interception + * seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent + * start/stop. It owns Claude payloads, environment, substitution, and decision + * mapping; shared execution and parsing live in `dsh-hook-protocol`. + * `updatedInput` is logged and warned but not honored. Bespoke behavior should + * use typed native plugins on the same seams; see the + * [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-claude */ @@ -55,7 +43,7 @@ export const inject = ['bash'] export interface Config { /** * Path to a `hooks.json` or a settings file whose `hooks` key holds the config. - * PROCESS-LEVEL: read once at load, a relative path resolves against the process + * Process-level: read once at load, a relative path resolves against the process * launch cwd, so one config applies to the whole process. * TODO(per-session-hook-config): per-session discovery of a project-local * `hooks.json` from each `session/new.cwd` is not yet implemented. @@ -104,14 +92,11 @@ function assertPositiveInteger(name: string, value: number): void { } export function apply(ctx: Context, config: Config): void { - // Validate the cap BEFORE the config-file parse: a bad value must fail the - // load loudly, not be skipped by the parse-failure early return. + // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - // --- Parse the config ONCE at load. A read/parse failure is contained: the - // bridge logs and registers nothing rather than crashing boot (a typo'd path - // must not take the agent down). --- + // Parse once at load. A read or parse failure logs and registers nothing. let parsed: ClaudeHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) @@ -128,11 +113,8 @@ 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. --- + // Emit-shaped points run detached, so track their chains; disposal aborts + // active hooks and drains continuations before resolving. const detached = createDetachedRuns() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') @@ -153,19 +135,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) { @@ -205,13 +179,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 { @@ -220,30 +188,15 @@ 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; a slow hook + // may miss the first request. + // 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) => { @@ -263,10 +216,8 @@ 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). + // Delegate so later listeners may still rewrite or block, then prepend our + // context only to a downstream allow decision. const downstream = await next() const ours = contextFrom(merged) if (!ours || downstream.kind !== 'allow') return downstream @@ -308,34 +259,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; hooks must self-limit meanwhile. 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 may inject child context; SubagentStop only observes. Both + // use the live child's workspace and the generic agent-type matcher subject. 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 }) @@ -346,13 +283,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..506d613c73 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -15,11 +15,8 @@ import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL - * bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook - * scripts written to a temp dir — only the model is mocked (the "prefer the real - * implementation" rule). Each test writes a `hooks.json` + executable scripts, - * loads the bridge pointed at them, and asserts the hook's effect on the loop. + * Full-loop Claude bridge tests with a mock model, the real loop and bash + * executor, and shell hooks from a temporary config. */ const dirs: string[] = [] @@ -193,7 +190,6 @@ describe('hooks-claude bridge — PostToolUse', () => { await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') - // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback. expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true) }) @@ -293,7 +289,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, adapter) // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's - // child lookup yields undefined and it simply runs the hook. + // child lookup yields undefined and it runs the hook. ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) @@ -302,12 +298,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). + // A marker proves only that the process ran. Disposal drains its detached continuation so the + // no-context branch completes before the per-file coverage snapshot instead of racing CI. await hooks.dispose() }) @@ -317,10 +309,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 PID and marker before sleeping past the suite timeout. Disposal must abort and + // kill the process rather than await its exit or the default ten-minute hook 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,14 +324,11 @@ 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. + // Disposal reaches quiescence: it returns only after the aborted run settles and the process + // is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. 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. + // runHook resolves an aborted run as a non-blocking error, so draining must + // not log a rejected continuation. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) }) @@ -367,11 +354,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). + // This is the only bridge mount, and its blocking hook would veto the prompt and log an event + // if its listener leaked after disposal. A no-op hook would not expose that leak. const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() @@ -393,10 +377,8 @@ 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. + // A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing + // load to fail. Guard the shape from postmortem 0001 directly. 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..f376708688 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -183,9 +183,8 @@ 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. + // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces + // continuation; the script self-limits to one block to avoid a loop. 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 +381,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. + // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the + // stop decision while execution and the turn continue normally. 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 +454,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 context-only hook delegates with `next()` and folds its context, so a downstream policy + // listener can still veto 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 +585,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 server launch directory and session cwd deliberately differ. The marker proves the + // bridge passes `session/new.cwd` instead of falling back to the executor default. const serverDir = dir() const sessionDir = dir() const marker = join(sessionDir, 'where') @@ -626,11 +620,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` recovers the child at `subagent/end`; a relative marker proves `runPoint` + // receives that agent and runs in the child's cwd rather than the executor default. const serverDir = dir() const childDir = dir() const marker = join(childDir, 'stopwhere') @@ -681,11 +672,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). + // Session-start injection is detached, so an immediate prompt need not observe it. Assert only + // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. 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 64ef369aca..e602ddb20c 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -1,16 +1,13 @@ /** - * Parse the bridge-supported subset of a Codex `hooks.json` into the shared - * {@link MatcherGroup} shape. The bridge accepts five events and the - * `{ type: 'command', command, timeout?/timeoutSec? }` hook shape, performs no - * config-time placeholder substitution or plugin-env injection, and skips - * non-command and `async: true` handlers with a warning. - * + * Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command + * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no + * command substitution. * @module @deepseek-ai/dsh-hooks-codex/config */ import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' -/** The five current Codex hook points this bridge supports. */ +/** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const /** A parsed Codex config: event name → its matcher groups (command hooks only). */ @@ -35,11 +32,8 @@ function asObject(value: unknown): Record | undefined { } /** - * Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s. - * Only the five bridge-supported {@link CODEX_EVENTS} are honored; another event is dropped. - * `type !== 'command'` and `async: true` command hooks are skipped (recorded in - * `skipped`). Malformed entries are ignored rather than thrown — a bad config - * must not crash boot. No config-time placeholder substitution is performed. + * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather + * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 445a68bf61..924b181151 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,16 +1,11 @@ /** - * `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. - * - * This bridge supports five of Codex's ten current hook points (`PreToolUse`, - * `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`), regex-only - * matchers, snake_case stdin payloads with `turn_id`/`model` extras and no - * trailing newline, no config-time placeholder substitution or plugin-env - * injection, and no pre-tool approval or rewrite path. The dialect-agnostic - * primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the - * Codex-shaped payloads, matcher mode, and decision mapping. - * + * Bridge for unmodified Codex command hooks on harness interception seams. It + * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only + * matchers, snake_case payloads without a trailing newline, no hook environment + * or command substitution, and no pre-tool approval or rewrite path; only + * blocking decisions are honored. Shared execution and parsing live in + * `dsh-hook-protocol`; see the + * [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex */ @@ -45,7 +40,7 @@ export const inject = ['bash'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** - * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative + * Path to a Codex `hooks.json`. Process-level: read once at load, a relative * path resolves against the process launch cwd. * TODO(per-session-hook-config): per-session project-local discovery from each * `session/new.cwd` is not yet implemented. @@ -81,8 +76,7 @@ function assertPositiveInteger(name: string, value: number): void { } export function apply(ctx: Context, config: Config): void { - // Validate the cap BEFORE the config-file parse: a bad value must fail the - // load loudly, not be skipped by the parse-failure early return. + // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS @@ -115,12 +109,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), 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 hooks in the agent's session workspace so relative paths address the + // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd for (const group of groups) { - // Codex matches with PURE regex (no literal fast path). + // Codex always interprets matchers as regexes; it has no literal fast path. if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) @@ -136,20 +129,12 @@ export function apply(ctx: Context, config: Config): void { defaultTimeoutMs, ...workdir !== undefined ? { cwd: workdir } : {}, ...opts.signal ? { signal: opts.signal } : {}, - trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline. + trailingNewline: false, // Codex writes stdin without a trailing newline. // 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. + // Clean plain stdout becomes context only when no structured context + // exists; nonzero output and raw JSON never leak as prose. if (opts.plainStdoutAsContext === true && output.exitCode === 0 && output.additionalContext === undefined && output.stdout.length > 0 && !output.stdout.startsWith('{')) { @@ -170,11 +155,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 @@ -182,25 +163,15 @@ 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; a slow + // hook may miss the first request. + // 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) => { @@ -211,7 +182,7 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-end */ }) - // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask). + // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 8c3bd665ce..d4a5797b96 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -15,10 +15,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash + - * REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`. - * Codex dialect specifics exercised here: regex matcher (substring), block-only - * decisions, the five-event subset. + * Full-loop Codex bridge tests with a mock model, the real loop and bash + * executor, and shell hooks from a temporary config. Covers regex matching, + * block-only decisions, and the five-event subset. */ const dirs: string[] = [] @@ -90,28 +89,23 @@ describe('hooks-codex bridge', () => { const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true) - // recorded under the codex dialect expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true) }) it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => { const dir = configDir() - // Block exactly ONCE (a marker file), then allow — without a one-shot guard a - // hook that always exits 2 would force-continue forever (the deferred - // stop_hook_active loop-guard is the real fix; here we self-limit so the test - // exercises the continue path without looping). + // Block once with a marker; until the loop guard lands, an always-blocking + // hook would never let this test finish. const marker = join(dir, 'fired') const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`) writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] }) - // Step 1 has no tool calls → would stop; the Stop hook forces step 2. const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // The Stop hook's reason became next-step steering → a second model request ran. expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') }) @@ -119,7 +113,6 @@ describe('hooks-codex bridge', () => { it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => { const dir = configDir() const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n') - // SubagentStop is a current Codex event that this bridge drops (no crash, no effect). writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('fine')]) @@ -127,7 +120,6 @@ describe('hooks-codex bridge', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // Ran normally; the unknown event was dropped at parse. expect(adapter.requests).toHaveLength(1) }) @@ -143,10 +135,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 leaked listener would let this blocking hook veto the prompt and log an invocation; a + // no-op hook would pass even when leaked. 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 +162,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 PID and marker before sleeping past the suite timeout. Disposal must abort the + // tracked process through `runPoint`, not await its natural exit. 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,14 +182,11 @@ 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. + // Disposal reaches quiescence only after the aborted run settles and the process is reaped, so + // `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. 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. + // runHook resolves an aborted run as a non-blocking error, so draining must + // not log a rejected continuation. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) }) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 774b308fa1..c287d86b23 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: the bridge delegates with `next()` and folds its context, so a + // downstream policy listener can still 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,9 @@ 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. + // SessionStart cannot block, but non-clean stdout still must not become context. The marker + // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches + // 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..30760a8fbc 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,8 @@ 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. + // Only swallow error-body parsing: status and code are already captured, + // so malformed gateway JSON must not mask the actionable HTTP failure. } 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..b816e2e7cf 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,20 +1,7 @@ /** - * 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] - * ``` - * + * Register a {@link DeepSeekAdapter} for configured model names on `ctx.llm`. Configuration uses + * Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`, + * as shown in the package README, rather than reading ad hoc files. * @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..6c43772dbc 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,17 +1,8 @@ /** - * 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 messages into DeepSeek chat completions. User text is joined; assistant text + * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. + * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by + * thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected. * @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..17080b10b3 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,15 +1,9 @@ /** + * Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines; + * CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal + * `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}. + * * 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..c66271246c 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -1,16 +1,10 @@ /** + * Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool + * call index. An empty initial reasoning delta does not open a block. Finish reason and the latest + * usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes + * while ensuring no chunk follows `finish`. + * * 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-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 3e533f8e7c..5944f8d30a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -187,7 +187,7 @@ describe('serializeRequest', () => { }) }) -describe('review fixes: assistant content shapes', () => { +describe('assistant empty and tool-call content shapes', () => { it('serializes a content-less, tool-call-less assistant message as null content', () => { // Aborted/empty assistant turns: no text, no calls → null (the wire // accepts it; "" is reserved for tool-call turns per the samples). diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 00107e8e1c..4b93b5e87d 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. - * + * Pi-ai-backed DeepSeek adapter and design twin of the hand-rolled adapter. + * Both implementations must fit the same provider-neutral stream vocabulary. * @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. + // Keep reasoning support enabled so `off` can send DeepSeek's explicit + // disabled marker rather than falling back to the provider's enabled default. 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 has no iterator-return cancellation hook. Chain an internal signal + // and abort it when this generator exits so early consumers stop the HTTP stream. 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..098b93edb3 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -1,20 +1,10 @@ /** * 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. - * + * Convert harness requests to pi-ai context and pi-ai assistant events to harness stream chunks. + * pi-ai parses tool arguments while the harness preserves raw JSON, so conversion parses inbound + * arguments and re-stringifies outbound values while the adapter restores provider payloads. + * In-stream pi-ai errors become harness error/aborted finishes, and its reasoning tokens remain + * folded into output usage because it reports no separate count. * @module dsh-llm-pi-ai/convert */ @@ -78,11 +68,8 @@ 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). + // Without this wire-field name, pi-ai replays an empty `reasoning_content`, violating + // DeepSeek's thinking-mode passback rule on tool-call turns. content.push({ type: 'thinking', thinking: block.text, thinkingSignature: 'reasoning_content' }) break case 'tool-call': diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cefaa9f745..f3f12666a3 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -312,7 +312,7 @@ describe('buildModel', () => { }) }) -describe('review fixes', () => { +describe('provider reasoning, passback, and early-stream cancellation', () => { it('defaults omitted reasoning config to thinking ENABLED (provider default)', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) // no reasoning key at all @@ -375,7 +375,7 @@ describe('review fixes', () => { }) }) -describe('review fixes: abort wiring', () => { +describe('caller cancellation', () => { it('honors a pre-aborted caller signal', async () => { const ctx = await harness('http://127.0.0.1:1') const controller = new AbortController() diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 65b60a36f6..296fd0c3d3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -35,7 +35,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### App attribution (`attribution.ts`) -Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). +Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution RFC](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). ### Classes @@ -46,7 +46,7 @@ Every product adapter must identify the application on every provider HTTP reque ### Real adapters -Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) uses `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. ## Model Experience diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 1b8ba6e60c..163bb5679a 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; ignoring re-close stragglers keeps streamed output + // and the final assembled block in agreement. 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..8f0f156aa1 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -1,14 +1,9 @@ /** + * Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping + * adapters from drifting. See + * `docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`. + * * 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..aa4c871e2f 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,14 +1,8 @@ /** - * 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. - * + * Conversation call configuration and freeze utilities. Model and sampling + * values are request-header state that can affect cache reuse; request + * waterfalls replace them and the loop logs changes instead of allowing + * silent per-call drift. * @module dsh-llm/call-config */ @@ -39,16 +33,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, guarding cycles, so later mutation throws. + * {@link AbortSignal} objects are deliberately skipped because they are the + * request's live cancellation channel and freezing them breaks abort. * @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..c1fdbb9ffa 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. - * + * Harness error base with a stable machine-routable code and chained cause. + * Package errors extend it so tool results and replay can retain failure class. * @module @deepseek-ai/dsh-llm/error */ diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 667e9bca78..08f3f54c51 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -54,23 +54,10 @@ 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. + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(models, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/never.ts b/packages/llm/llm/src/never.ts index 1243611415..e50a7478df 100644 --- a/packages/llm/llm/src/never.ts +++ b/packages/llm/llm/src/never.ts @@ -1,31 +1,14 @@ /** - * 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. - * + * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a + * new variant fails compilation at every required handler. Do not use it for declaration-merged + * unions such as session events or content blocks: handle known variants and explicitly fall + * through because plugins may add valid unknown cases. * @module @deepseek-ai/dsh-llm/never */ /** - * Marks unreachable code on a closed union. If this is reachable, either a - * variant was added without updating the switch (compile error at the call - * site — the desired outcome) or a value escaped its type (runtime throw - * with diagnostics — the safety net). + * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site; + * a value that escaped its type throws with diagnostics at runtime. * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site. * @param context - optional label (e.g. the switch site) prefixed into the throw message. * @returns never — it always throws, with the offending value JSON-rendered in the message. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index e3339869a5..8f5fcf0d1e 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,22 +1,7 @@ /** - * 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 } - * } - * } - * ``` + * Canonical provider-neutral message and streaming vocabulary for the loop, + * session log, and plugins. Adapters alone translate provider wire shapes; + * mapped interfaces make the content, source, and finish unions extensible. */ import type { Branded } from '@deepseek-ai/dsh-brand' @@ -53,15 +38,8 @@ export interface ToolResultBlock { } /** - * All known content block shapes, keyed by their `type` tag. - * Merge-extensible: plugins add new block types via declaration merging. - * - * The core set is deliberately limited to blocks every shipping path honors. - * Multimodal content (images, audio, …) has no core block type: a feature - * that needs one adds it via declaration merging in the same coordinated - * change that maps it in the adapters, surfaces it in the UI bridges, and - * prices it in compaction — a producer never lands without its consumers - * (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md). + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. */ export interface ContentBlockMap { 'text': TextBlock @@ -126,25 +104,10 @@ 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. + * Block indexes correlate interleaved deltas, and `block-end` carries the + * assembled block. Adapters emit usage before the terminal finish and nothing + * afterward; tool arguments remain raw JSON strings. Failures either throw or + * end with `error`/`aborted`, and consumers must handle both paths. */ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } @@ -193,17 +156,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.) + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index d9a4fe33f3..5612e93cb4 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. + // Unknown declaration-merged block types cannot be assembled from partial deltas. Opening a + // plugin-added `video` block without its required `block-end` exercises that failure. assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk) expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"') }) @@ -135,13 +133,9 @@ describe('assertNever', () => { }) }) -describe('BlockAssembler regressions (property-test findings)', () => { +describe('BlockAssembler duplicate-close contract', () => { 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. + // The first close wins so streamed and final output cannot disagree. 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 5a56a16356..fa0ff7257f 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -1,14 +1,16 @@ # @deepseek-ai/dsh-sandbox-local -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. +Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. -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`. +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. + +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. -The Landlock launcher comes from the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) — an entry package (this package's one runtime dependency) plus per-platform binary packages selected by npm's `os`/`cpu` fields, built and released from [its own repository](https://github.com/deepseek-harness/node-addon-landlock-run). The entry package owns the launcher's CLI contract: `launcherPath()` resolution (a host with no platform package yields a never-existing path whose probe fails exactly like an unenforcing kernel), the functional `probe()`, and `grantArgs()` flag spelling — versioned together with the binary, so probe-report parsing can never drift against it. This provider keeps only the policy side: the mode → grants mapping (`landlockProfileArgs`) and the ladder. The consumer path is rehearsed by `tests/packed-install.e2e.ts`: pack THIS package's closure, install into a throwaway consumer with the launcher family coming from the registry, assert the installed binary executable (a stripped mode bit must not masquerade as a non-enforcing kernel), and confine through it under plain `node`. +[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. -Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, `tests/seatbelt.e2e.ts`), each self-skipping where its runner is absent; CI's `sandbox-e2e` matrix runs all of them against real kernels (bwrap plus one Landlock leg per architecture on Linux, Seatbelt on macOS) and fails on a silent all-skip. +Each rung has a self-skipping keyless world-effect test; CI runs platform legs against real kernels and rejects a silent all-skip. The packed-install test exercises the registry launcher and executable mode through a plain-Node consumer. ```yaml - id: sandbox @@ -19,7 +21,7 @@ Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the ## Model Experience -Indirectly, through `dsh-bash-sandbox` and `dsh-tool-bash`, which render this provider's enforcement dialect as the exact `[sandbox: file access denied under mode]` marker or the [`dsh-sandbox`](../sandbox/README.md) `SANDBOX_UNAVAILABLE` text while keeping runner selection and profiles outside context. +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context. ## Known Limitations and Deferred Work diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 66f3077319..cc464eb5c4 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,24 +1,8 @@ /** - * `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. - * + * Local sandbox backend. It selects the platform runner chain (Linux bwrap then + * Landlock; macOS Seatbelt), functionally probes competing candidates once, and + * reports each wrap's enforcement and stderr dialects. Missing or unusable + * confinement fails closed rather than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ @@ -35,20 +19,10 @@ 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 runner argv; bwrap-shaped profile arguments are appended. A + * non-empty override asserts full enforcement and skips built-in selection and + * probing; a broken runner then fails at execution and must be identifiable by + * {@link runnerFailureSignatures}. */ runnerCommand?: string[] /** @@ -60,28 +34,15 @@ export interface Config { * own failure dialect. */ runnerFailureSignatures?: string[] - /** - * Per-probe timeout in milliseconds for the chain's functional probes - * (default: 5000; must be a positive finite number — Node treats a 0 - * `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A - * probe that exceeds it reads as an unusable rung, so a - * host slow enough to trip the default — cold NFS mounts, heavily loaded - * CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no - * config escape. Bounds ONE probe, and the chain walk runs each at most once - * per provider lifetime. - */ + /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ probeTimeoutMs?: number } /** - * 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). + * Build a bwrap profile: the host is read-only with fresh `/dev` and `/proc`; + * workspace-write overlays writable temp and workspace mounts. PID and network + * isolation are intentionally outside the file-effect policy. + * * @param policy - the file-effect policy to express as bwrap arguments. * @returns the bwrap profile arguments (before the trailing `--` + argv). */ @@ -95,19 +56,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. + * Build Landlock grants for the same file policy without synthetic mounts. + * Read-only grants only `/dev/null` for writes; workspace-write also grants the + * host temp root and workspace. + * * @param policy - the file-effect policy to express as launcher grants. * @returns the launcher grant arguments (before `--` + argv). */ @@ -130,10 +82,7 @@ function canonicalPath(path: string): string { try { 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. + // An unresolved grant matches nothing until the named path exists; keep its spelling. return path } } @@ -144,20 +93,10 @@ 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. + * Build a Seatbelt profile that denies file writes then allows `/dev/null` and, + * for workspace-write, the canonical workspace, host temp, and per-user macOS + * temp roots. Network and process visibility remain unrestricted. + * * @param policy - the file-effect policy to express as an SBPL profile. * @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv). */ @@ -170,17 +109,7 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { return ['-p', forms.join(' ')] } -/** - * Functional `bwrap` probe: can it actually build the read-only profile on - * this host? (`--version` alone would miss a disabled unprivileged user - * namespace.) Synchronous by design — it runs once, lazily, before the first - * confined wrap, and the chain's verdict is cached for the provider's - * lifetime. `timeoutMs` bounds the probe (the `probeTimeoutMs` config). - * The Landlock rung needs no such helper: resolution (`launcherPath`) and - * the functional probe (`probe`) come from `node-addon-landlock-run`, the - * package family that ships the launcher binary itself, so the probe-report - * parsing can never drift against the binary. - */ +/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */ function defaultProbeBwrap(timeoutMs: number): boolean { const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: timeoutMs, @@ -239,13 +168,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 +202,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 +214,9 @@ 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. + * Runner-owned stderr prefixes cover both internal refusal and shell-level + * not-found errors. Consumers match these before denial text because the + * command never ran on this path. */ const RUNNER_FAILURE_SIGNATURES = { bwrap: ['bwrap: '], @@ -356,17 +269,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 +286,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 +334,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. + // A sole candidate needs no arbitration; its execution-time refusal still fails closed. 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..1e818e1bdf 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -9,22 +9,11 @@ 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 backend integration through `confine()` and a real bwrap process. With no rung forced, + * a passing probe must select the first rung. Tests assert world effects, wrap shape, and that the + * kernel denial matches the advertised dialect; consumer coverage lives in dsh-bash-sandbox. + * Skips when bwrap or user namespaces are unavailable. HOME-based workspaces avoid bwrap's + * ephemeral `/tmp`, so workspace-write actually proves the workspace-root rebind. */ 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..f5ecbc67f9 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 backend integration through `confine()` and the registry `landlock-run` launcher, with + * bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox. + * Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid + * Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself. */ 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..c221544861 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -252,19 +252,15 @@ 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. + // Only a cast can create this rogue closed-union tag. It must hit `assertNever`, ensuring a new + // runner cannot silently use another runner's wrap or denial dialect. 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..62cb56dc31 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -7,30 +7,14 @@ 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. + * Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact + * tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher + * plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files, + * dependency errors, or lost executable modes. * - * 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). + * The installed launcher must match the host architecture, remain executable, and either confine a + * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or + * before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -84,11 +68,8 @@ 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. + // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. 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..2c5a026ddb 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -9,20 +9,11 @@ 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 backend integration through `confine()` and a real macOS Seatbelt process, with Linux + * rungs forced off. Tests assert world effects and that the kernel denial matches the advertised + * dialect; consumer coverage lives in dsh-bash-sandbox. Skips off macOS or when the profile probe + * fails. HOME-based workspaces avoid Seatbelt's wholesale temp-directory grants, so + * workspace-write proves the workspace-root grant itself. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 0e77b52b29..93e274b485 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -12,7 +12,17 @@ Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: ` ## Model Experience -Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and `dsh-tool-bash`, which render this seam's enforcement facts as the exact denial or `SandboxUnavailableError` text documented by the consumer, with retained tokens added only for a denial or failed confinement. +### Confinement error, indirectly + +**What the model sees**: Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: `. + +**Token effect**: Conditional error text is visible for that call and retained in history until compaction. + +#### Exact error + +```markdown +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +``` ## Known Limitations and Deferred Work diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 55b9da4540..75cea5ecfe 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. - * + * Same-world process-confinement seam: wrap exact subprocess argv under a + * host-path file policy. Containers, microVMs, and remote execution replace the + * surrounding capability seam instead; this service shares the host kernel and filesystem. * @module @deepseek-ai/dsh-sandbox */ @@ -26,25 +9,10 @@ import { Context, Service } from 'cordis' 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}. + * File-effect policy for confined processes. `read-only` permits only required + * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a + * backend-defined temp area; `danger-full-access` bypasses confinement. Network + * and process visibility are outside this vocabulary. */ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -52,18 +20,9 @@ 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. + * Enforcement completeness for this host. `partial` means an active backend or + * older kernel ABI cannot govern every promised file effect; callers requiring + * an absolute boundary must not treat it as `full`. */ export type SandboxEnforcement = 'full' | 'partial' @@ -103,36 +62,25 @@ 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`). + * Case-insensitive signatures for runner failure before command execution. + * Consumers check these before denial signatures: runner failure means the + * command never ran, while denial means confinement worked and blocked it. */ runnerFailureSignatures: readonly string[] } /** - * Error `code` carried by the infrastructure error a provider throws when a - * confined policy is requested but no backend is available or usable on this - * host: confinement FAILS CLOSED (refuses to run) rather than silently - * executing unconfined. Thrown as a `HarnessError`, it reaches the model - * through the structured `{ name, code }` error channel on `tool/result`, so - * callers can distinguish "the sandbox is missing" from a failing command. + * Error code for a requested confined mode when no backend is usable. The + * provider fails closed, and `HarnessError` carries the code through + * `tool/result` so callers can distinguish missing confinement from command + * failure. */ export const SANDBOX_UNAVAILABLE = 'SANDBOX_UNAVAILABLE' /** - * Thrown by {@link SandboxProvider.confine} when a confined policy is - * requested but no backend is usable on this host: confinement fails closed. - * Carries the {@link SANDBOX_UNAVAILABLE} code through the structured - * `{ name, code }` error channel. + * Thrown when {@link SandboxProvider.confine} cannot enforce the requested + * mode. Carries {@link SANDBOX_UNAVAILABLE} through the structured error + * channel. */ export class SandboxUnavailableError extends HarnessError { constructor(mode: ConfinedSandboxMode, detail?: string) { @@ -155,27 +103,10 @@ 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. {@link confine} must return enforcing argv + * or fail closed at wrap or runner-execution time; silent unconfined passthrough + * is forbidden. Functional probes arbitrate multi-runner chains and may be + * skipped for a sole candidate, whose own refusal remains the fail-closed end. */ export abstract class SandboxProvider extends Service { constructor(ctx: Context) { diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 6f9bad85f7..343fb4a70a 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,12 +23,12 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). -- **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. +- **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. +- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. ## Write path -The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (copy each already-frozen event into the persistence-owned write-behind buffer), and `session/flush`/dispose (drain that buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown. +The plugin buffers frozen session events and drains them on flush or disposal. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Operations for one session are serialized; disposal waits for initialization and the final drain so no write lands after teardown. ## Model Experience diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5ec01283b4..39bdecf751 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -72,19 +72,13 @@ 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. + * Safe code units remain literal; every other unit, including `~`, becomes + * `~XXXX`. Operating on code units preserves lone surrogates, while special- + * casing `.` and `..` prevents traversal by an otherwise safe whole segment. * - * 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`. */ @@ -142,38 +136,18 @@ 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`). + * 0). Fully written events in an interrupted final turn remain part of the + * prefix. The first unparsable record or seq gap after the last `turn/end` + * marks a tolerated torn tail; the same hole in the committed region rejects. * - * 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). + // Track complete lines by byte offset: a non-newline tail is torn and ignored, + // and a running counter avoids rescanning a long multi-byte log. const lines: { text: string; endByte: number }[] = [] let start = 0 let byteOffset = 0 @@ -201,14 +175,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. + // Parse every complete record first so the last valid `turn/end` determines + // whether an earlier hole is committed corruption or an uncommitted tail. interface Parsed { ok: boolean; event?: SessionEvent; endByte: number } const parsed: Parsed[] = eventEntries.map((entry) => { try { @@ -226,18 +194,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. + // Preserve the contiguous prefix, including a complete interrupted turn; + // holes through the last committed boundary throw, while later holes stop. 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 4265d1ccf1..d5922afa94 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -1,19 +1,7 @@ /** - * 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. - * + * JSONL durable session-persistence backend. It stores a header and contiguous + * events in one append-only file per session, and delegates orchestration to + * {@link PersistenceCoordinator}. * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -41,13 +29,7 @@ export interface Config { root: string } -/** - * Whether `error` is a "no such file/directory" (`ENOENT`) failure — the ONLY - * filesystem error that legitimately means "this session/root is absent" for a - * durable backend. Any OTHER error (`EACCES`, `ENOTDIR`, transient I/O) must - * surface rather than be silently reported as absence. (A NodeJS filesystem - * rejection carries a string `code`.) - */ +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } @@ -65,13 +47,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi }) /** - * Backend label for the coordinator's dispose-failure AggregateError and - * effect name. NOTE: this intentionally shadows cordis `Service.name` (which - * the base sets to `'sessionPersistence'`). The service is registered under the - * fixed key the Service constructor captured (`reflect.provide('sessionPersistence', …)`), - * not via `this.name`, so overwriting the instance field with the backend label - * does not affect `ctx.sessionPersistence` resolution — it only relabels the - * dispose diagnostics, which is exactly what {@link PersistenceBackend.name} is for. + * Backend label for coordinator diagnostics and effects. It shadows + * `Service.name` without changing the service key captured by the base + * constructor. */ override readonly name = 'session-persistence-jsonl' @@ -80,10 +58,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 once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -105,11 +80,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. + // One method serves both public `list` and the backend hook; delegating it to + // the coordinator would call this hook recursively. /** * The per-session init promises, exposed for white-box tests that await a @@ -122,7 +94,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - /** Read a stored prefix by id across ALL cwd buckets (cwd unknown). */ + /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ async loadStored(id: SessionId): Promise | undefined> { const file = await this.findLog(id) if (file === undefined) return undefined @@ -130,11 +102,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Read a stored prefix SCOPED to `cwd` (HMR live-adoption must not cross cwd). - * `undefined` is the DEFINITE "no-cwd" bucket, NOT "unknown" — a live session - * with no cwd may only adopt a persisted no-cwd log, never a same-id log that - * lives in some other cwd bucket. So this looks at exactly `logPath(cwd)` - * (which maps `undefined` → the `_no-cwd` bucket), never the all-buckets scan. + * Read a stored prefix within one cwd for HMR adoption. `undefined` names the + * no-cwd bucket rather than an unknown cwd, so this never scans other buckets. */ async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { const path = logPath(this.root, cwd, id) @@ -143,10 +112,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Read and scan a session's log file into a {@link StoredPrefix}. Folds the - * torn-tail comparison HERE so the `tornMarker` is the byte offset to truncate - * to (or `undefined` when nothing is torn) — the coordinator never sees the - * raw byteLength. + * Read a stored prefix and convert torn-tail state to the byte offset the + * coordinator can round-trip without knowing the file format. */ private async readPrefix(path: string): Promise> { const buffer = await readFile(path) @@ -182,9 +149,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi const metas: SessionHeader[] = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { - // Read ONLY the header line, not the whole log: a session picker must - // scale with the number of sessions, not the total size of every - // conversation (the log persists every assistant/chunk verbatim). + // Read only headers so listing scales with session count, not log size. const first = await this.readFirstLine(`${dir}/${name}`) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) @@ -205,10 +170,7 @@ 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.) + // Materialization is the first write; an existing log is an id collision. /* 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)`) @@ -225,28 +187,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } finally { await handle.close() } - // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the - // final path already exists, so two processes materializing the same id - // concurrently cannot clobber each other. rename() would silently overwrite. + // Publish with link()+unlink(): unlike rename(), link fails if another + // process materialized the same id first. let linked = false try { 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. + // Remove an unpublished temp on failure. After publication, defer cleanup + // until the directory entry is durable so cleanup cannot reject a live log. /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ if (!linked) await rm(tmp, { force: true }) } - // link() succeeded — the log is published. fsync the directory so the new - // entry survives a power loss: the new link is not crash-durable until the - // parent directory's metadata is synced. + // The published link becomes crash-durable only after its directory fsync. 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 { @@ -265,11 +221,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Append event lines at EOF and fsync. On a write/sync failure AFTER the kernel - * accepted some bytes (ENOSPC, an fsync error), truncate the file back to its - * pre-append size before rethrowing: the cursor is unchanged, so the batch will - * be retried, and without this rollback the retry would append AFTER the partial - * bytes — producing duplicate seqs that make `scanLog` see a gap. + * Append and fsync event lines. On a partial write or sync failure, restore the + * previous size before rethrowing because the unchanged cursor will retry the + * batch; leaving partial bytes would create duplicate sequence numbers. */ private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const path = logPath(this.root, meta.cwd, meta.id) @@ -331,10 +285,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Find a session's log file by id across ALL cwd buckets — the any-cwd scan - * for `loadStored` (resume identifies a session by id alone). The cwd-scoped - * lookup (`loadLive`) does NOT use this; it goes straight to `logPath(cwd)` so - * a no-cwd session can't match a real-cwd bucket. + * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption + * bypasses this scan so a no-cwd session cannot claim another bucket. */ private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { const target = encodeSegment(id) + '.jsonl' @@ -355,9 +307,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. + // Only an absent root means no sessions; rethrow every other I/O failure. 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 757ba03aac..a4f0334f50 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -54,12 +54,8 @@ 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. +// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial, +// newline-less fragment past the committed region so coordinator repair runs on real file bytes. runCoordinatorContract('jsonl', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) return { @@ -347,10 +343,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. `loadCore`, not this scanner, later closes the orphaned turn. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) }) @@ -470,9 +465,8 @@ 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. + // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving + // `readFirstLine` accumulates chunks before `list()` parses it. 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) }) @@ -492,10 +486,8 @@ 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 Session object reuses the id. Object-keyed initialization must run independently, + // detect the disk collision, and reject instead of appending through session A's stale cursor. const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { @@ -513,13 +505,9 @@ 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 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id, + // undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead + // of grafting no-cwd events onto a log with mismatched cwd. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) @@ -587,9 +575,8 @@ 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". Making the root a + // regular file forces ENOTDIR from `readdir`, which must propagate. const filePath = join(root, 'not-a-dir') await writeFile(filePath, 'x') const ctx2 = new Context() @@ -600,11 +587,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 per-id open error must surface rather than become "not found" and permit false + // live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path. const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) @@ -723,10 +707,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. The error therefore surfaces synchronously at append, not later during backend flush. 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/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1fdd6dac61..1411e177be 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,13 +8,13 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). -- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. +- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. ## Configuration (schemastery) @@ -39,7 +39,6 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer ## Known Limitations and Deferred Work -- **Raw `node:sqlite`, pending a cordis database service** — the backend holds a `DatabaseSync` directly; if a `cordis/db` / `@cordisjs` SQL driver is adopted, the storage driver routes through it (the `SessionPersistence` contract would not change) — a marked TODO. - **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 30387b4837..32b8f4ade8 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -1,19 +1,7 @@ /** - * 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. - * + * SQLite durable session-persistence backend. It maps each session header and + * event to rows, and delegates write-path orchestration to + * {@link PersistenceCoordinator}. * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -89,10 +77,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 asynchronously so directory creation does not block plugin apply; + // every storage hook awaits the same readiness promise. this.ready = this.openDb(config.path, (config as Required).journalMode) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -121,10 +107,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. + // One method serves both public `list` and the backend hook; delegating it to + // the coordinator would call this hook recursively. /** * 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..adb23cbb43 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -56,35 +56,17 @@ 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 and apply its schema and pragmas. A zero `user_version` is + * stamped with {@link SCHEMA_VERSION}; every other non-current version rejects + * rather than being migrated in place. * @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 +75,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 +142,11 @@ 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). + * Find the preserved prefix of ordered event rows. Fully written rows in an + * interrupted final turn remain in the prefix. The first unparsable row or seq + * gap after the last `turn/end` marks a tolerated torn tail; the same hole in + * the committed region rejects. * - * 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 +170,8 @@ 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). + // Preserve the contiguous prefix, including a complete interrupted turn; + // holes through the last committed boundary throw, while later holes stop. 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..e3429e5835 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -28,8 +28,7 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () = return { ctx, dispose: () => fiber.dispose() } } -// The payoff: the SAME backend-agnostic contract the JSONL backend runs, now -// proving the SQLite backend satisfies identical semantics. +// Run the same backend-agnostic contract as JSONL to pin identical semantics. runPersistenceContract('sqlite', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -40,11 +39,8 @@ 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. +// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid +// JSON past the committed seq, exercising coordinator repair against real database rows. runCoordinatorContract('sqlite', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) const path = join(dir, 'sessions.db') @@ -66,9 +62,9 @@ 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. Surface metadata is serialized to + // its nullable columns so the conversion remains faithful. const rows = (events: SessionEvent[]): EventRow[] => events.map((e) => { const se = e as SessionEvent @@ -256,11 +252,9 @@ 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). The merged v4 cannot interpret that + // ambiguous, incomplete layout and must reject it. const path = await freshDbPath() openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) const db = openDatabase(path, 'wal') @@ -277,11 +271,9 @@ 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. + // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary + // from seq/type columns without parsing the tail, preserves the committed prefix, and load + // deletes the row; invalid JSON inside the committed region would remain fatal. 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/README.md b/packages/session-persistence/session-persistence/README.md index 2e3b3e23a2..f09a21251b 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -22,9 +22,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l ## The write coordinator -The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, 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 differed (write bytes vs. INSERT rows). - -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. The correctness-heavy orchestration therefore has one implementation and one place for fixes. +`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The `PersistenceBackend` hooks (the only seam between the coordinator and storage): diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b1fc118a21..cbdb9ba25f 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -1,26 +1,7 @@ /** - * 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). - * + * Shared buffering, serialization, adoption, repair, and disposal orchestration + * for first-party backends. Third-party backends may implement the public + * persistence seam directly. * @module @deepseek-ai/dsh-session-persistence/coordinator */ @@ -30,16 +11,9 @@ import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek- import { 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 header, valid contiguous event prefix, and optional opaque + * torn-tail marker. The coordinator only checks marker presence and returns its + * value to {@link PersistenceBackend.commitRepair}; each backend owns the type. */ export interface StoredPrefix { meta: SessionHeader @@ -112,16 +86,9 @@ 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 lazy creation has produced a durable artifact. The first append + * atomically materializes the header with events; reclaim logic uses this to + * distinguish an unused id from a persisted collision. */ materialized: boolean /** @@ -166,14 +133,9 @@ export class PersistenceCoordinator { */ private chains = new Map>() /** - * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not - * its id: a disposed fiber's session can be replaced by a different live - * Session reusing the same id (HMR, an ACP reconnect), and an id-keyed cache - * would hand the new object the old object's init promise. - * - * Public (readonly) so a backend can expose it for white-box tests that await - * a specific session's init (there is no public API to await one init); the - * coordinator itself only ever mutates it internally. + * Init promises keyed by live session object, preventing an id-reusing + * replacement from inheriting stale initialization. Readonly access supports + * backend white-box tests. */ readonly inits = new Map>() @@ -184,16 +146,11 @@ export class PersistenceCoordinator { // --- public surface (the backend's service methods delegate here) --- /** - * Register a new session's metadata (lazy: no physical write until the first - * {@link append}). Rejects if the id is already tracked or already persisted. - * @param meta - the header (id, version, cwd, lineage) to record; materialized - * as a detached lossless-JSON snapshot at call time. + * Register detached session metadata for lazy creation on the first append. + * @param meta - header to snapshot; duplicate tracked or persisted ids reject. */ create(meta: SessionHeader): Promise { - // Snapshot the metadata at call time: the op runs later (behind the - // per-session chain) and the snapshot is stored as the lazy state, so keeping - // the caller's object by reference would let a later mutation of `id`/`cwd` - // register under one key but materialize under a different path/header. + // Snapshot before queueing so caller mutation cannot diverge the key and header. const snapshot = snapshotJsonValue(meta) if (snapshot === undefined) { return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable')) @@ -274,32 +231,20 @@ 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. + // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(events) const balanced = [...events, ...closers] - // Make the repair durable (truncate the torn tail + append the synthetic - // closers) BEFORE recording state — commitRepair takes `meta` directly, so - // there is no state-path ordering dependency (uniform across backends). + // Repair storage before publishing coordinator state. if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) } - // The state keeps its OWN copy of the meta; the returned value is separate so - // a consumer mutating loaded.meta cannot corrupt the backend's metadata. + // Keep coordinator metadata detached from the returned record. this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) 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. + // Listing is a direct backend read and needs no coordinator state. // --- per-id serialization + adoption helpers --- diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index b03691d17b..87bbdde129 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. - * + * Durable session-persistence seam (`ctx.sessionPersistence`). Backends store + * {@link SessionEvent}s as the event-sourced log and carry non-replayable + * {@link SessionHeader} metadata separately. * @module @deepseek-ai/dsh-session-persistence */ @@ -39,12 +23,8 @@ 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. - * - * 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. + * Check whether a live seed exactly reproduces a durable prefix, including full + * payloads. This distinguishes resume/HMR rebinding from an id collision. * @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 +52,10 @@ 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 events.** `SessionEventMap` is merge-extensible, so - * {@link append} materializes each complete batch through the shared - * lossless-JSON boundary before buffering it. The public `session.events` - * view is immutable, but persistence still snapshots direct/replay callers at - * this independent trust boundary. - * - **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). + * Durable append-only session storage. Implementations preserve contiguous, + * losslessly JSON-serializable events; {@link append} resolves only after + * durability, and {@link load} balances a complete interrupted tail without + * rewriting committed events. */ export abstract class SessionPersistence extends Service { constructor(ctx: Context) { @@ -125,29 +83,12 @@ 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. - * - * 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. + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. * @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. + * @returns the header and a log ending on a balanced `turn/end`. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 789aa72c91..d8cd9fc230 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -43,15 +43,9 @@ 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 recorded events to a live session while forwarding surface metadata verbatim. The broad + * `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a + * surface event whose fixture omitted it; this helper never synthesizes a default. */ export function appendLog(session: Session, events: readonly SessionEvent[]): void { for (const e of events) { @@ -222,10 +216,10 @@ 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. Each value is carried in a plugin-added field on one + // user message so the contract covers the complete JSON-value boundary. 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 61d37964ce..ffe5c7f6bd 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -1,28 +1,11 @@ /** - * 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. + * Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}. + * Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring, + * lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload, + * flush, and disposal quiescence through public APIs rather than storage primitives. * + * Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs + * retain only storage-mechanics tests, while these scenarios run once per backend. * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract */ @@ -39,25 +22,12 @@ import { meta, oneTurnLog, appendLog } from './contract.ts' * the suite mounts/disposes backend instances on it and cleans it up at the end. */ export interface CoordinatorFixture { - /** - * Mount the REAL backend plugin (via `ctx.plugin`, the Loader path) on `ctx`, - * over THIS fixture's shared storage scope. Returns the plugin fiber so the - * suite can dispose a single instance (HMR/reload) while the storage — and any - * still-live session in another fiber — survives. The caller has already - * mounted `SessionStore` on `ctx`. - */ + /** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */ mount: (ctx: Context) => Promise /** - * Inject a NEVER-COMMITTED torn tail into the backend's storage for `id` at - * the given `cwd` (the cwd the session was created with): a half-written - * record past the committed region (JSONL: a partial line with no newline; - * SQLite: a row with invalid `data` JSON past the committed seq). This drives - * the coordinator's `loadCore` `tornMarker !== undefined` → `commitRepair` - * branch against real storage. - * - * OMITTED by a backend that structurally has no torn tails (memory): the - * torn-tail scenario then self-skips (asserted explicitly in the suite). + * Inject a never-committed partial record after the durable region so `loadCore` reaches + * `commitRepair`. Omit only when the backend structurally cannot produce torn tails. */ corruptTail?: (id: SessionId, cwd: string | undefined) => Promise @@ -124,10 +94,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). JSONL stores it in the header; SQLite uses `seed_length`. const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { @@ -213,10 +182,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }) it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => { + // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation. const fix = await makeFixture() const first = await freshCtx(fix) try { - // First lifecycle: persist a session through the store. const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) @@ -224,9 +193,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.fiber.dispose() } - // Second lifecycle: a NEW backend instance + a session re-created with the - // same id SEEDED with the loaded events. onCreated adopts the stored log - // (does not re-persist the seed); a new turn appends at seq 6. const second = await freshCtx(fix) try { const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) @@ -237,7 +203,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.parallel('session/flush', s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - // 6 original + 2 new, contiguous, no duplicated seed. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) } finally { await second.fiber.dispose() @@ -304,10 +269,9 @@ 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. The new instance has no coordinator state but must adopt the + // materialized prefix, then persist another turn rather than rejecting it as a collision. await backend1.dispose() await fix.mount(ctx) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -399,9 +363,9 @@ 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, and `flush()` surfaces + // that initialization rejection. 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 9c766d4b66..13943d35a7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -16,18 +16,10 @@ 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). + * Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a + * dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple + * instances share materialized sessions, the in-memory analogue of reload over one file/database; + * durable behavior is covered by the JSONL and SQLite backends. */ class MemoryPersistence extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] @@ -88,8 +80,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend } const existing = this.store.get(m.id) if (!existing) { - // First batch: `_isMaterialized` is false (the coordinator only omits - // materialization on the first batch); writing the entry IS the materialization. + // The coordinator sends the first batch for materialization; later batches append. this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] }) } else { existing.events.push(...structuredClone(events) as SessionEvent[]) @@ -122,12 +113,8 @@ 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. +// Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are +// atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { const store: MemoryStore = new Map() return { diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 50584c95ef..9449a40ec5 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -28,7 +28,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. This provider ships no built-in system skills; another provider can supply embedded or remote built-ins. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request. diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 5d25d1cb5d..8edcd71ef0 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -21,11 +21,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously from its `apply()` and returns `readonly SkillCandidate[]` from `list(options)` when discovery is requested. The provider, lookup options, candidates, and loaded definitions are readonly same-process contracts: the registry borrows them rather than cloning, freezing, or rebinding callbacks. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading. +A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. -The registry validates parsed provider candidates before caching them and validates loaded definitions before returning them. The winning provider receives the exact candidate and opaque `locator` identity it returned from `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. Callers and providers must honor the readonly contract after handing values to the registry. +The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Parsed candidate and loaded-definition fields are validated at the provider boundary: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Candidate contract violations fail fast because the provider or its parser is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. +Contract violations fail fast. A rejected `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. ## Runtime Skills diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 5ef3f78465..7523f562a5 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -175,14 +175,9 @@ 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. Providers - * are readonly same-process registrations: the registry borrows the provider - * object and invokes its methods directly. Effect-scoped and HMR-safe: - * disposing the caller's fiber unregisters the provider and invalidates - * cached catalogs. + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. * @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. @@ -215,16 +210,11 @@ export class SkillService extends Service { } /** - * Register a runtime skill contribution. Runtime registrations are treated as - * embedded provider entries with project-over-user priority. Same-name runtime - * registrations are first-wins: a duplicate logs a warning and gets a no-op - * disposer so it cannot remove the active contribution. Runtime definitions - * are readonly same-process registrations; the registry borrows their nested - * resource metadata. + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. * @param skill - the complete skill definition to expose for discovery. - * @returns the exact Cordis effect disposer that removes this runtime - * contribution and invalidates caches; composite effects may yield it - * directly to preserve teardown ordering. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) @@ -266,12 +256,9 @@ export class SkillService extends Service { } /** - * Load one full skill definition by name. The provider receives the winning - * candidate it returned during discovery, including its opaque locator, and - * the registry returns the provider's definition after validating it. - * Cancellation is rechecked after catalog - * selection (including a cache hit), and provider loading is raced against the - * same signal so an uncooperative provider cannot hang the caller. + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index f2573ac18d..a2c7ff5c40 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -16,7 +16,11 @@ The plugin contributes one user-role `` catalog through `agent/ |---|---|---| | `name` | string (required) | Exact kebab-case skill name from the available skills listing. | -Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns one text tool result with ``, containing `` followed by ``. Resource guidance resolves paths or URLs explicitly referenced by the loaded instructions against `resourceBase`; referenced scripts, references, and assets load only when needed, and the tool does not enumerate a skill directory. Local filesystem skills provide a base directory, while remote or embedded providers can provide a URL or opaque provider-managed guidance. A name that cannot be resolved reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation: true` retain distinct `isError` results. +Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing ``, ``, and ``. + +Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance. + +An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 39fa0253b4..50c4b0db74 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 teardown removes guidance first. Exact definition + // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. 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..80766ed831 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -1,20 +1,8 @@ /** - * 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). - * + * Out-of-process ACP subagent backend. Each child has its own process, session, model, and + * tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent- + * enforced start capabilities. This plugin uses named exports only; a default would hide its + * loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`). * @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 5222906a2d..9416d58865 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -1,24 +1,10 @@ /** - * 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. + * Fresh-process ACP subagent client. Drives one child session and owns cancellation and + * quiescent disposal. * + * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and + * sessions root inside each child process. Current keyless coverage uses a scripted ACP child; + * with-key coverage drives the real ACP example. * @module @deepseek-ai/dsh-subagent-acp/run */ @@ -42,16 +28,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' -/** - * How the client answers a child's `session/request_permission`. The first cut - * does not surface permission prompts to a human, so every request is - * auto-answered by this fixed policy: - * - * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child - * that asks before a side effect does not get to take it. - * - `allow` — approve every prompt by selecting its first `allow_*` option (or, - * if none is offered, `cancelled`). Use when the child is trusted to act. - */ +/** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' /** Resolved spawn spec for an ACP child process (no defaults — see Config). */ @@ -95,18 +72,7 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** - * 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. - */ +/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ @@ -174,16 +140,9 @@ 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 after publication resolves with `stopReason: 'error'`. A spawn, - * initialize, new-session, or pre-publication cancellation failure instead - * rejects only after the process has been reaped. `dispose()` requests ACP - * cancellation, then kills and reaps the subprocess. + * Start and publish one ACP child after initialization and session creation. + * Child failures resolve through the run result; startup failures reject after + * process reap. Disposal cancels, kills, and reaps the child. * @param request - the start request; its signal is the cancellation channel. * @param spec - the resolved spawn spec: command/args/cwd, env, permission * policy, dispose graces, and the optional error sink. @@ -194,24 +153,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') - // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP - // response channel, stderr = INHERIT so the child's diagnostics surface on the - // parent's stderr (no separate capture to drain — we don't fold child stderr - // into the result; the seam reports only output + stop reason). + // Keep diagnostics on parent stderr; only ACP output contributes to the result. const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: buildChildEnv(spec.env), stdio: ['pipe', 'pipe', 'inherit'], }) - // Same-tick capture (the library's contract): a spawn-level failure (e.g. - // ENOENT for a bad command) is an `error` EVENT that would crash the parent - // unheard; the result path races this promise, so a bad command settles - // `error` like any child failure. + // Capture the child-process error event immediately. const spawnFailed = spawnFailure(child) - // One memoized quiescence transaction is shared by startup rollback and the - // published run's disposer. Once start fulfills, only the holder can invoke - // it; before fulfillment the provider invokes it on every failure path. + // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, { disposeEofGraceMs: spec.disposeEofGraceMs, @@ -220,12 +171,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] - // `cancelled` records that the required signal or disposal requested 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`). + // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } const makeClient = (_agent: AcpAgent): Client => ({ @@ -261,28 +207,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe ) 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 signal/dispose cancellation 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). + // Cancellation settles the result without waiting for a cooperative child. let signalCancelSettled!: () => void const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) const requestCancel = (): void => { if (flags.cancelled) return 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 ACP cancel; process teardown remains authoritative. /* v8 ignore next */ if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) } @@ -324,12 +256,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const result: Promise = (async (): Promise => { try { - // Race two post-publication 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 (`result` settles `aborted`). After `newSession` - // succeeds, transport/process failure rejects the in-flight prompt RPC. + // Race the remote turn against local cancellation. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) @@ -340,23 +267,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) } catch (error: unknown) { - // A deterministic cancellation resolves `cancelSettled` before its - // best-effort ACP cancel can reject the prompt. This fallback is only for - // a process/pipe rejection already queued when the abort event fires; its - // first-outcome ordering cannot be forced without a timing-dependent test. + // Cover a process rejection already queued when cancellation arrives. /* v8 ignore next */ if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } - // The seam contract: result resolves (never rejects) on a child-level - // failure. Startup failures were already rejected before publication; - // every rejection here is a prompt transport/RPC failure. - // Flatten to `error` and surface the original via onError so a real fault - // is preserved rather than silently lost. + // Flatten post-publication transport failures while preserving diagnostics. try { spec.onError?.(toError(error), 'error') } catch { - // Swallows only the caller-supplied sink's OWN throw: an unguarded - // sink exception would reject `result` and break the contract above. - // The child-level failure being reported still settles as `error`. + // The diagnostic sink cannot reject the run result. } return { output: collectOutput(), stopReason: 'error' } } finally { @@ -372,15 +290,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (disposal !== undefined) return disposal request.signal.removeEventListener('abort', onAbort) requestCancel() - // 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). + // The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces + // from stdin EOF, including the final flush, so this backend uses a wider + // EOF grace before signals escalate. disposal = disposeProcess() return disposal }, diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index fb200f3505..000f6d49f0 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -1,44 +1,9 @@ /** - * 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. - * + * Minimal no-network ACP child process for keyless backend tests. Environment variables script its + * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a + * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark + * SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with + * an explicit tsconfig, mirroring real example boot. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ @@ -159,11 +124,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 receives cancellation but neither resolves nor exits. The + // backend must still settle `aborted`, and disposal must kill the process. return Promise.resolve() } resolveCancel?.('cancelled') @@ -180,12 +142,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. READY_FILE proves the trap was armed before the test disposes the run. 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. @@ -193,13 +152,10 @@ 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 its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves +// the EOF grace window was long enough for durable flush. if (FLUSH_ON_EOF !== undefined) { const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { @@ -210,14 +166,9 @@ 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 before SIGKILL. The signal +// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE +// proves the handler was armed before disposal. if (process.env.MOCK_IGNORE_EOF === '1') { const sigtermFile = process.env.MOCK_SIGTERM_FILE process.on('SIGTERM', () => { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 75e4febc3e..73070ab585 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -9,16 +9,9 @@ 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 cross-process seam proof: the backend spawns the real acp-agent example, speaks ACP over + * stdio, and returns its real model answer. This is the out-of-process counterpart to in-process + * spawn coverage and self-skips without `DEEPSEEK_API_KEY`. */ // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index ebf64e7b70..fa212bc938 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -1,22 +1,9 @@ /** * 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. The seed ends at the last `turn/end`: the current tool-call turn is + * unbalanced and cannot be replayed as a valid child session. * @module @deepseek-ai/dsh-subagent-fork */ @@ -45,12 +32,10 @@ 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`. The in-flight turn is excluded; before any completed turn the child starts + * fresh. Because live sequence numbers equal array indexes, the result remains a valid seed + * beginning at sequence zero. * @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 e089cebe5f..b5e129e54f 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -135,10 +135,9 @@ 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. The seed must stop after the + // balanced first turn; including the open turn would fail invariant replay during start. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) parent.send([{ type: 'text', text: 'q1' }]) await parent.whenIdle() @@ -183,12 +182,8 @@ 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. + // `readResult` must scan only child-owned events after the seed. The child emits no assistant + // message, so scanning the whole log would incorrectly return the parent's distinctive text. 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/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 16ecad7bd4..09aa2d24b7 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -1,42 +1,12 @@ /** - * 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: - * - * - The scoped capture tool and instruction are ordinary assembly inputs. The - * loop logs the assembled request header, so the demand is reconstructable - * log state rather than a wire-only mutation. As with every other assembly - * contribution, an expert `system-prompt/assemble` listener that deliberately - * removes or replaces either input owns the resulting composition. - * - `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 tool, prompt instruction, terminal guard, and authoritative + * result capture for in-process subagents. Each child registers its real schema on its own + * scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt + * contribution is ordinary reconstructed request state. * + * Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also + * waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard + * then prevent later listeners or calls from reopening a completed structured run. * @module @deepseek-ai/dsh-subagent-inprocess/structured */ @@ -70,11 +40,8 @@ export interface StructuredAttachment { } /** - * 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. + * Attach the scoped capture tool, instruction, and enforcement to a child during + * its creation window. Child disposal removes every registration. * @param childCtx - the child agent's scope context (`setup`'s argument). * @param schema - the trusted, already-asserted schema subset to enforce (see * `assertSupportedOutputSchema` in dsh-tools). diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3e23123622..a2c55995c2 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -36,12 +36,9 @@ const SCHEMA: StructuredOutputSchema = { } /** - * Real loop + scripted mock model + an INLINE fresh-conversation provider over the - * shared driver. The concrete backend plugins are deliberately NOT loaded — - * they would devDep-cycle this package (spawn/fork already depend on the - * driver), and the runtime under test is the driver's; plugin-level structured - * coverage lives in the spawn/fork specs. The mock model script drives the - * child's structured_output calls. + * Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading + * spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while + * this fixture isolates driver behavior and scripts the child's `structured_output` calls. */ async function setup(script: Script, options: SetupOptions = {}) { const ctx = new Context() @@ -216,11 +213,9 @@ describe('in-process structured output', () => { ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) let wrapperInstalled = false - // Register before the ready-only start. The child session-start boundary is - // after unpublished setup attached structured output but before the loop - // can run. The wrapper awaits the - // explicit downstream stop above, then overwrites that result with continue. - // The later terminal checkpoint still wins. + // Register before ready-only start: structured output is attached before session-start and the + // loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose + // to the later terminal checkpoint. ctx.on('agent/session-start', (child) => { if (child === parent) return wrapperInstalled = true @@ -244,10 +239,8 @@ 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. + // A downstream policy stops, then a later wrapper delegates and queues steering that ordinary + // folding would turn into continue. The terminal checkpoint must discard that steering. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 6d2d8f3a11..c006272cd5 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 child factory already provides it during setup, +// and adding it here would unnecessarily change this provider's apply timing. 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 9e4e489882..43ec8ee1ac 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -152,11 +152,8 @@ describe('dsh-subagent-spawn', () => { }) it('rejects without publishing 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. + // An already-aborted signal emits no future event, so start must check it before listening and + // settle aborted without running the child. The empty model script proves no turn occurs. const controller = new AbortController() controller.abort() const { ctx, parent } = await setup([]) @@ -165,10 +162,8 @@ describe('dsh-subagent-spawn', () => { }) it('same-tick cancellation rejects start and prevents child publication', async () => { - // Regression: cancellation before publication used to set a flag but let the - // async factory publish a child anyway, so `started` fulfilled and lifecycle - // observers saw an agent for an attempt the caller had already cancelled. - // The empty script also proves no model turn can run. + // Same-tick cancellation must win before async factory publication: no child may become + // visible, `started` must not fulfill, and the empty script proves no model turn occurs. const { ctx, parent } = await setup([]) const beforeAgents = ctx.agents.list().length const beforeSessions = ctx.sessions.list().length diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 35d7383456..21bcca788e 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -1,20 +1,8 @@ /** - * 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. This package + * registers no provider; consuming plugins own and validate every timing or path default. * @module @deepseek-ai/dsh-subagent-subprocess */ @@ -52,11 +40,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 `error` event as a promise. Call in the same tick as + * `spawn()`; otherwise an early event can be unhandled and crash the parent. * @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 +110,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, resolving only after exit: close stdin and allow + * cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit. * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. @@ -141,10 +119,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. Close stdin and allow cooperative teardown and durable-state flush. 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 +148,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, independent of host CLI state. Without + * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory + * is returned unchanged and remains deployment-owned. * * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. @@ -209,10 +177,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..bdc0260c73 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 real-passthrough except for one deterministic failure. Permission-based recursive-rm +// failures are not portable and disappear under root, so this is the sanctioned filesystem seam. vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, rm: vi.fn(actual.rm) } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 3bdc78569a..05bb40d575 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -11,16 +11,12 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { StructuredOutputSchema, ToolRestriction } 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). These static flags cover features needed before a run exists; runtime + * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence + * is the capability. */ export interface SubagentCapabilities { /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ @@ -60,14 +56,9 @@ export interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Optional structured-output schema — an object-rooted JSON Schema within the - * enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema - * outside the subset is rejected loud at start). When set AND the provider's - * {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to - * report a value matching this schema, surfaced as - * {@link SubagentResult.structured}. The schema must be plain host-realm JSON - * data — a caller holding foreign-realm data materializes it first. - * Requesting it against a provider that lacks the capability is rejected at start. + * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; + * a successful child returns the matching value as {@link SubagentResult.structured}. */ readonly outputSchema?: StructuredOutputSchema /** @@ -136,14 +127,9 @@ export interface SubagentResult { } /** - * A live subagent run: a handle the consumer holds while a child executes. - * Returned by {@link SubagentProvider.start} (via the service) only after the - * child is ready. The consumer awaits {@link result} and MUST {@link dispose} - * on every path to cancel any remaining work and reach child quiescence. - * - * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports - * the runtime capability defines the method; one that doesn't omits it. The - * presence of the method IS the capability — narrow before calling. + * Child handle returned only after readiness. Consumers await {@link result} and must always + * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime + * capability discovery; narrow their presence before calling. */ export interface SubagentRun { /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ @@ -188,15 +174,9 @@ export interface SubagentProvider { /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities /** - * The provider's conversation-history descriptor: `true` when a child SEES the parent - * conversation (fork — the child is seeded with the parent's completed-turn - * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, - * not a start-time capability: the service validates nothing against it — - * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool - * wording from it, so a tool bound to a fork provider stops telling the - * model the child "does not see this conversation". This descriptor concerns - * conversation history only; it says nothing about tool registrations, - * injected services, or authority inheritance. + * Whether the child sees the parent's completed-turn prefix. This is descriptive, not a + * service-validated start capability: the model-facing tool derives truthful wording from it. + * It says nothing about tool registration, injected services, or authority inheritance. */ readonly inheritsParentContext: boolean /** diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d07e6726dd..ef75d1fe97 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,34 +1,11 @@ /** - * 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 conversation-history - * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, - * ACP) gets the standalone-prompt wording, while a seeded-conversation provider - * (fork) tells the model the child already sees the conversation's completed - * turns. This descriptor says nothing about Cordis scope, services, tools, or - * authority. 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. + * Model-facing delegation tool bound by configuration to one provider; transport selection is not + * exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and + * re-derives conversation-history wording after reload, so load order is irrelevant. * + * Execution synchronously awaits the child result and always disposes the run. Non-completed stop + * reasons become error results, while transport details remain behind `ctx.subagents`. Load this + * plugin more than once to expose multiple configured providers. * @module @deepseek-ai/dsh-tool-subagent */ @@ -105,16 +82,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. + // Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which + // silently means deny all. Preserve omission while retaining an explicit empty allow-list. 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[]), @@ -290,7 +259,7 @@ export function apply(ctx: Context, config: Config): void { if (present !== undefined) { mount(present) } else { - // Not an error: the backend's fiber may simply activate after this one. + // Not an error: the backend's fiber may 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/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index b9f51bb2b9..7835deb432 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -37,9 +37,9 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. -The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's Markdown prompt snapshot from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, and prompt snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). +`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. ## Model Experience diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index f2970d1340..7fd8cf25a8 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -1,18 +1,8 @@ /** - * 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 ACP snapshot subprocess harness. It boots the real agent bin through the Cordis + * loader, drives deterministic ACP JSON-RPC over stdio, captures protocol-pure stdout, and + * harvests persisted session logs after graceful shutdown. Normalization stays in + * `normalize.ts`; suite registration stays in `suite.ts`. * @module @deepseek-ai/dsh-acp-snapshot/harness */ @@ -66,16 +56,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. `promptAndCancel` sends without awaiting, + * waits for the first streamed message, then cancels, making transcript order deterministic. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -92,16 +76,9 @@ 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). + * FIFO permission answers selected by stable option kind; the harness maps each kind to the + * agent-issued option id. Exhaustion cancels, while a kind the agent did not offer fails the + * scenario. */ permissionAnswers?: PermissionAnswer[] } @@ -201,8 +178,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 +204,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 the same raw bytes to the golden and SDK client. Decode once at the end so a UTF-8 + // sequence split across stream chunks cannot corrupt the transcript. const passthrough = new Readable({ read() {} }) child.stdout.on('data', (buf: Buffer) => { rawBuffers.push(buf) @@ -255,13 +228,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 callback throw would become only an RPC error the agent could absorb. Record an + // impossible permission choice here, answer cancelled, and fail the outer scenario. let scriptError: Error | undefined const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { @@ -356,10 +324,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 +345,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 +355,8 @@ 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. + // A hang fixture never resolves alone. Wait for its streamed chunk before cancellation + // so updates deterministically precede the cancelled prompt response. 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 +442,8 @@ 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. + // Match replay fixture assignment: primary first, then children by creation time, with id as + // a deterministic collision tiebreaker. 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..5afb7ecf66 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,17 +1,7 @@ /** - * 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: subprocess scenario harness, pure golden normalizers, and the Vitest + * suite factory behind `pnpm run test:snapshot`. Because this entry exports `suite.ts`, importing + * it requires a Vitest run. * @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..81ee42e9e3 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 ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids, + * timestamps, and hook duration while preserving deterministic event sequence numbers. + * Request-header scrubbers stay separate so one scenario per header class can pin tools and a + * readable prompt while other fixtures omit duplicated header bulk. * @module @deepseek-ai/dsh-acp-snapshot/normalize */ @@ -68,12 +47,10 @@ 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. + * Invalid JSON throws, doubling as a protocol-stdout purity check. * * @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..8930b03d2f 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -1,33 +1,12 @@ /** - * 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). + * Keyless-by-default ACP snapshot suite factory. Each scenario drives the real subprocess and + * compares normalized stdout; comparable session fixtures are both replay input and expected + * output. Record mode refreshes reproducible model scenarios from the live API, while refresh + * mode replays committed scripts and rewrites derived artifacts without a key. * + * Exactly one scenario per header-composition class pins tool schemas in JSONL and the system + * prompt in Markdown. Every live header is checked against that pin, so session-dependent + * composition must declare a separate class instead of escaping coverage. * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -89,20 +68,8 @@ 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 is its header class's sole request-header pin. Its Markdown file owns + * the prompt, its JSONL keeps tool schemas, and every classmate is checked for equality. */ pinsHeader?: boolean /** @@ -164,17 +131,9 @@ 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 normalization values from a fixture's own session header. Recorded ids and cwd differ + * from the live replay run; the non-empty sentinel for missing cwd avoids accidental empty- + * string replacement. * * @param fixture The committed `session.jsonl` content. * @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}. @@ -419,10 +378,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 +401,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 +412,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 writes live model fixtures; keyless refresh writes every comparable replayed + // fixture. Pins keep tools but all JSONL files scrub prompt text. const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders @@ -515,14 +464,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 +474,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 +525,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 +544,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 +559,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..d5fcd75a93 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,7 @@ /** - * 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 ACP agent for snapshot-kit tests. A fixture-adjacent `behavior.json` controls the + * subprocess reached through the real harness path; the bin reports observations over ACP and + * writes scripted logs before exiting on stdin EOF. */ 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..01b93dc81e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -308,11 +308,8 @@ 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 offers only allow_once/reject_once. The harness must reject an impossible click, + // not merely send an RPC error that a tolerant agent could absorb. 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..c76dd32a92 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -18,21 +18,14 @@ 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. + * 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 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. + * Record tests use a temp copy. To intentionally rebuild their committed fixtures, run this + * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree. */ const AGENT = { @@ -44,12 +37,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/README.md b/packages/support/invariants/README.md index 7a4f9fc263..54fb26b333 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -39,7 +39,7 @@ Agent status (per agent): Model requests (on `llm/stream`): -- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the fold of the log's `request/header*` events (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. +- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` is rebuilt through a fresh `Session` from the prefix before its in-flight `step/start`; later content belongs to the next request, and hand-built unfrozen one-shots are excluded. Frozen messages must match that derivation, while every other field matches folded `request/header*` events. The prepended check runs before ordinary short-circuiting stream listeners, but correctness comes from the sequence boundary rather than listener timing. See the [reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). On any violation it throws `InvariantError` (`code: 'INVARIANT'`). diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 15a57ab08a..f5b7828a3d 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,21 +1,9 @@ /** - * Runtime invariants: a pure-listener plugin that asserts relationships in - * the harness event contract. It is intended for development diagnostics but - * has no environment guard, so it is active in every composition that mounts - * it (including the default `dsh-agent-core` bundle). - * - * Everything is a plugin — this is just listeners on `session/created`, - * `session/event`, `agent/status`, and the scoped dispatch and request seams. - * Custom compositions can omit it when the runtime assertion cost is - * undesirable. When mounted, a contract violation is a loud failure rather - * than a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below are the contract. - * - * Session owns immutable log storage: it snapshots and deep-freezes every - * accepted event at the source. This plugin checks relationships that one - * event's types and immutability cannot express, including turn/step nesting, - * scoped dispatch, status transitions, and request reconstructability. - * + * Runtime listeners that fail loudly when cross-event contracts are broken: + * turn and step nesting, scoped dispatch, status transitions, and request + * reconstruction. The plugin has no environment guard and is active wherever + * mounted, including the default `dsh-agent-core` bundle; custom compositions + * may omit it. Sessions still own event snapshots and freezing. * @module @deepseek-ai/dsh-invariants */ @@ -330,19 +318,15 @@ function replayEvent(trace: SessionTrace, event: SessionEvent): void { applyTransition(trace, validateEvent(trace, event)) } -/** Legal agent status transitions (the only state machine the loop guarantees). */ +/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { - // First observation: any status is a valid starting point. if (from === undefined) return - // A no-op transition is illegal — setStatus dedups, so we never see it. if (from === to) { throw new InvariantError(`agent/status repeated ${to} (no-op transition)`) } - // Leaving `disposed` is illegal — disposal is terminal. if (from === 'disposed') { throw new InvariantError(`agent/status left terminal state disposed → ${to}`) } - // idle↔running and (idle|running)→disposed are all legal; nothing else exists. } /** diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index d82affe4e7..07dc75377c 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -325,19 +325,17 @@ describe('HMR state rebuild', () => { it('rebuilds trace state for a session that exists at (re-)apply time', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - // First registration, mid-turn: a turn is open when the plugin reloads. const first = await ctx.plugin(Invariants) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) await first.dispose() - // Re-apply (HMR): the fresh fiber must replay the existing log so the open - // step is known — the next chunk must NOT be a false positive. + // Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log. await ctx.plugin(Invariants) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) .not.toThrow() - // And a genuine violation is still caught after the rebuild. + // Rebuild must not disable later violations. expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) .toThrow(/turn 1 is still open/) }) @@ -541,25 +539,17 @@ 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. + // Create an impossible-through-public-API gap so seq 2 is earlier but unknown. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) - // Push a fake event at seq 3 into the internal log, creating a gap at seq 2. - // The invariants plugin replays session.events on every append, so it sees - // this gap during trace reconstruction. ;(session as unknown as { log: unknown[] }).log.push({ type: 'assistant/chunk', seq: 3, 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). expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) }).toThrow(/unknown seq 2/) @@ -803,12 +793,8 @@ 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.) + // Replay short-circuits without next(), so the check prepends ahead of ordinary listeners; + // correctness still comes from its sequence-bounded rebuild, not listener timing. 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..2e509973e5 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -1,50 +1,8 @@ /** - * 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). - * + * Keyless snapshot-test LLM replay. It derives one model-call script per + * recorded session from `assistant/chunk` events and binds fresh live sessions + * to parent/child scripts by first-call order. Throw and hang cases require an + * explicit override because a session log cannot reconstruct them alone. * @module @deepseek-ai/dsh-llm-replay */ @@ -56,21 +14,9 @@ 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. `throw` may replay prefix chunks before failing; + * `hang` models cancellation. Only ordinary chunk entries derive from JSONL; + * the other variants come from an override sidecar. */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } @@ -102,13 +48,8 @@ export interface ReplayConfig { } /** - * One recorded session's replay script: the per-call entries plus the header - * facts needed to ORDER and key it. Live session ids are freshly random at - * replay time and never equal the recorded `id`, so the recorded id is only a - * diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it - * (a parent is created before its children) and each newly-seen live session is - * bound to the next script in that order (= first-call order in the synchronous - * nested cut, where the parent streams before it delegates). + * Recorded calls plus header facts used to order parent and child scripts. + * Recorded ids are diagnostic; fresh live ids bind by ordered first use. */ export interface SessionScript { /** The recorded session id (diagnostics only — the live id differs). */ @@ -134,9 +75,7 @@ export interface SessionScript { export function parseSessionLog(text: string): SessionEvent[] { const lines = text.split('\n').filter(line => line.trim().length > 0) const events: SessionEvent[] = [] - // Skip line 0 (the header). A reader distinguishes it by its `type:'session'` - // tag; we simply drop the first line, which the JSONL backend guarantees is - // the header. + // The JSONL backend guarantees line 0 is the session header. for (let i = 1; i < lines.length; i++) { const parsed: unknown = JSON.parse(lines[i] as string) events.push(parsed as SessionEvent) @@ -145,14 +84,8 @@ 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 replay identity, ordering, and fork-seed facts from the JSONL header. + * * @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 +102,9 @@ 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. + * Groups `assistant/chunk` events by turn and step. Every group must end in a + * `finish`; a missing terminator means the live stream threw, so derivation + * rejects and the scenario must provide an explicit override. * @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 +163,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 the primary and child scripts in bind order. Child derivation begins at + * `seedLength` so inherited parent chunks are never replayed as child calls. * - * 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 +187,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 +197,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 +241,11 @@ 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 per-session positional replay. A newly seen live session takes the + * next ordered recorded script, then advances its own cursor synchronously at + * invocation time; calls without `sessionId` share one anonymous session. + * Returns the effect disposer for HMR-safe removal. * - * 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 3b4e8c5fee..ac52ec11c9 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 log includes the parent's assistant chunks before `seedLength`. Deriving from the + // whole log would replay parent responses as child calls, so only child-owned chunks qualify. 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,8 @@ 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). + // The primary is appended first internally. A strictly earlier child sorts before it, while + // equal creation times preserve primary-first order 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 abc1a6c5e9..5032c92529 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -1,13 +1,7 @@ /** - * 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). - * + * Scripted, model-free subagent provider for deterministic coverage of registration, + * capability checks, lifecycle, the model-facing tool, and structured results through the real + * loader path. It is a named-export functional plugin; no default export. * @module @deepseek-ai/dsh-subagent-mock */ @@ -28,12 +22,7 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } -/** - * A scripted provider: every {@link start} returns a ready run whose `result` - * resolves on the next task with the configured reply (and a structured value - * when the request asked for one and the capability is on). The required - * signal and `dispose()` both flip an unsettled result to `aborted`. - */ +/** Scripted provider whose configured result aborts if disposed or signalled first. */ class MockSubagentProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities readonly inheritsParentContext: boolean diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 7fd93c62bc..c9700a14b1 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -105,10 +105,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. + // A default export would make Loader unwrap only that value and drop `inject`. 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 49f62f9e81..e946c2af61 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -1,34 +1,7 @@ /** - * `@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). - * + * Cooperative tool-call timeout enforcer. A tool declares `timeoutMs` and + * promises to honor `exec.signal`; this wrapper arms that deadline and maps its + * own expiry to `TOOL_TIMEOUT` without racing or abandoning the tool promise. * @module @deepseek-ai/dsh-timeout-policy */ @@ -68,22 +41,9 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { } /** - * 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 timeout wrapper. It resolves the caller-visible tool definition, + * temporarily replaces `exec.signal`, delegates, restores the upstream signal, + * and replaces the result only when this wrapper's own timer fired. */ 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..039d2085f7 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -1,21 +1,7 @@ /** - * 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). - * + * Model-facing whole-list replacement. Each call appends a `todo/write` snapshot to the calling + * agent's session; replay is last-write-wins, and UIs render from session events. A non-agent + * caller has no owning list and is rejected. Named exports preserve loader injection metadata. * @module @deepseek-ai/dsh-tool-todo */ @@ -42,20 +28,9 @@ 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}[]: trimmed non-empty unique content and at most one in-progress item. The registry + * has already enforced the status enum; the cast below records that guarantee. */ 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..c2843cbcfe 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. + // A default export would make Loader unwrap only apply and drop `inject`. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-todo') expect(tool.inject).toEqual(['tools']) diff --git a/packages/ui/README.md b/packages/ui/README.md index fdd0bc694b..02dfcfbdce 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -11,12 +11,12 @@ Integrations that expose the agent to an external editor or client. These are ** | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -| `jsonrpc/` | Stdio JSON-RPC SDK server plugin: serves `HarnessSdkServer` to out-of-process SDK clients (the Python SDK) on the process stdio | (drives `ctx.agents`) | -| `jsonrpc-agent/` | JSON-RPC SDK server APP: a bin-only boot of an external `cordis.yml` whose `jsonrpc` entry is the serving face; the single-exe runtime entrypoint | (`bin` only) | +| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | +| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. +A UI integration is a client-driver plugin, not a loop change or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -`stdio-agent` and `acp-agent` are the two composing **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. `jsonrpc-agent` is the third app but bin-only — no composition plugin, because the SDK runtime's hard semantic is that the external `cordis.yml` composes everything, the serving `jsonrpc` entry included. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. +`stdio-agent` and `acp-agent` compose the [`agent-core`](../core/agent-core/README.md) spine with their front-door plugins and own their boot bins; a leaf `cordis.yml` supplies backends and optional tools. `jsonrpc-agent` is bin-only because its external config also chooses the serving `jsonrpc` plugin. Each lives in `ui/` as a user-facing front door whose artifact owns its stdout policy. diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index ddad5f0d21..62e562ad58 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -1,30 +1,12 @@ #!/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 [--config path-to-cordis.yml]` (default - * `./cordis.yml`). - * + * Boot an ACP stdio server from `cordis.yml`; usage is + * `dsh-acp-agent [--config path]`, defaulting to `./cordis.yml`. Shared env + * loading, Loader guards, snapshot config selection, and settled-tree boot live + * in dsh-app-boot. Replay skips `.env` and selects sibling + * `cordis.snapshot.yml` so a stray key cannot trigger a model call. EOF disposes + * and flushes snapshot runs; editors normally own process lifetime. Stdout is + * reserved for JSON-RPC, so diagnostics go only to stderr. * @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 43186dfcb0..8f76728d8f 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -1,32 +1,11 @@ /** - * 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}), + * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It + * writes nothing to stdout. + * It pre-creates no agents and leaves adapters, executors, and optional tools to + * the leaf, which must likewise avoid stdout loggers. Named exports are + * required so Loader retains this plugin's `Config` schema (see + * docs/postmortem/0001). * @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..52f62d8ac8 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -139,14 +139,8 @@ 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. + // A default export would make `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. expect('default' in 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 ac6965ff65..c6130130b9 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -18,20 +18,10 @@ 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. + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and + * require a valid initialize response. This catches built-only settle races and stdout protocol + * leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a + * dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -48,12 +38,8 @@ 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. +// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict +// layout need not hoist them. Symlink those exact paths into the plain-Node consumer. const npmDeps = ['@agentclientprotocol/sdk', 'zod'] const acpPkgDir = join(repoRoot, 'packages/ui/acp') @@ -161,18 +147,15 @@ 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 nonexistent directory prevents even the include plugin import. Loader logs the failure and + // leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit. 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 963b440373..2fd6db54d4 100644 --- a/packages/ui/acp-agent/tests/load-path.e2e.ts +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -17,22 +17,11 @@ 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. + * Source-path Loader smoke through the package's own bin, covering initialize, session/new, and + * session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and + * unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd + * is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable + * when the child starts outside the repository. */ const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) @@ -131,13 +120,10 @@ 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"). Persistence + // and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches + // not-found, while a collapsed export would fail earlier with missing injection. 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 8329c2d999..dd705e8d8d 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `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 the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. +The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -26,60 +26,47 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: |---|---|---| | `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` | | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | -| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load | +| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles 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, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | -| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | +| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | -| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" | +| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | | `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. +Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). ## Session config options -When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6. +When `ctx.permission` is composed, the bridge advertises one `permission` select in `session/new` and `session/load`. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-config-options). -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. +Background bash tasks use the session id as an opaque owner token, so one session cannot inspect or stop another's task. That contract belongs to [`dsh-tool-bash`](../../bash/tool-bash/). ## Per-session cwd -Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). The server may be launched outside every workspace: an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.) +`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported. ## Tool-call presentation -How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards: - -- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along). -- `{ 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. - -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. +Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation). ## Terminal card (capability-gated) -A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: - -- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card. -- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. - -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's unfenced `output` — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). +When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). ## Settle-exactly-once -A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending. +A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue. ## Permission prompts -The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority. +For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge. ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`. ## stdout is the protocol diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 36ed963b14..121af7cc2f 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult ## 6. Session modes / config options / models -Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector). +Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector). ## 7. Content blocks diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 3830dba676..d03fdcb277 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, total translation between harness vocabulary and ACP wire types. * @module @deepseek-ai/dsh-acp/codec */ @@ -16,29 +10,11 @@ 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) + * `completed` and the defensive `error` case map to `end_turn`; + * `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map + * to `cancelled`. The bridge rejects error turns before this mapping. Unknown + * merge-extensible kinds use legal fallback `end_turn` rather than breaking + * the prompt RPC. * @param reason - the harness turn-end reason to translate. * @returns the legal ACP wire value per the mapping above. */ @@ -56,23 +32,17 @@ 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' } } /** - * Translate a harness {@link ContentBlock} from a prompt into ACP content for - * replay, or `undefined` for block kinds the bridge does not surface to the - * client as message content. Today only `text` maps; `resource_link` is an - * ACP prompt-only input rendered into text by {@link acpPromptToText}; - * `reasoning` is surfaced via `agent_thought_chunk` - * streaming rather than as a message block, and `tool-call`/`tool-result` - * are handled by the tool-call update path. + * Map replayable text to ACP message content. Other block kinds use their + * prompt, thought-stream, or tool-update paths. * @param block - the harness content block to translate. * @returns the ACP block, or `undefined` for a kind with no message-content mapping. */ diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 25f2ea20f2..c464ffd22b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,37 +1,8 @@ /** - * 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. - * + * Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes + * agents, routes their events, settles prompts by turn, and answers approvals. + * Each session keeps independent presentation and prompt-correlation state so + * concurrent streams cannot cross. Stdout is reserved for protocol frames. * @module @deepseek-ai/dsh-acp */ @@ -100,32 +71,16 @@ 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. -// TODO(acp-session-inject): drop `sessions`; this bridge never reads -// ctx.sessions, and agent/session ownership is already behind ctx.agents. +// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction. +// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`. export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] -/** - * Build an ACP "invalid params" error whose human detail rides in the message. - * `RequestError.invalidParams(data, additionalMessage)` keeps the standard - * "Invalid params" message and appends `additionalMessage`, so we pass the - * detail as `additionalMessage` (and no structured `data`). - */ +/** Build an ACP invalid-params error with visible human detail. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** - * Build an ACP "internal error" whose human detail rides in the message. Used - * to reject a `session/prompt` whose turn ended in failure: a plain `Error` - * thrown from a method handler is flattened to a generic "Internal error" on - * the wire, so we wrap the detail in the SDK's `RequestError.internalError` - * (which appends `additionalMessage`) to surface *why* the turn failed. - */ +/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -248,13 +203,7 @@ function stringArrayContent( export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } @@ -262,69 +211,25 @@ export const Config: Schema = Schema.object({ model: Schema.string(), }) -/** - * Per-session bridge state. One per live ACP session; held in the `sessions` - * map keyed by id (RFC 011 multi-session). - */ +/** Per-session bridge state keyed by ACP session id. */ interface SessionRecord { sessionId: SessionId agent: Agent - /** - * The owned-agent disposer (from the {@link AgentHandle} the factory returned). - * Teardown calls it to unregister this ONE agent, stop its loop, await - * quiescence, and remove its session — instead of leaving it for the bridge - * fiber to reclaim. - */ + /** Owned-agent disposer that reaches per-session quiescence. */ dispose: () => Promise - /** - * Resolves tool-owned presentation for THIS session's tool calls and remembers - * each in-flight call's `(name, args)` so the matching `tool/result` can find - * its tool. Per-session so two concurrent sessions never cross their in-flight - * tool state. - */ + /** Per-session tool presenter and in-flight call correlation. */ presenter: ToolPresenter - /** - * Whether THIS session renders shell tools as terminal cards — snapshotted - * from the client's `_meta.terminal_output` capability at session creation - * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per - * session means the `tool_call` (which registers the terminal) and the matching - * `tool_call_update` (which streams its output) ALWAYS agree, even if a later - * `initialize` mutates the connection-level capability between them — otherwise - * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal, - * result terminal) or clobber the card (call terminal, result non-terminal). - */ + /** Session-creation snapshot of terminal-card support for call/result consistency. */ 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 by its matching - * `turn/end`, direct cancellation, or teardown. - * - * `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. - * - */ + /** In-flight prompt and its captured turn number for exact settlement. */ inflight: { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined } | 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. + * Idle config changes awaiting a turn-enclosed log anchor; last write wins. + * Responses overlay them, but a restart before anchoring restores the logged fold. */ pendingSwitches: { preset?: string } } @@ -336,44 +241,25 @@ interface SessionRecord { * correlation in a `finally` so presentation failure cannot starve settlement. */ 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. + // Handlers run later outside this injection scope, so capture services now. const agents = ctx.agents const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools const userInteraction = ctx.userInteraction - // A new ToolPresenter per session (and a throwaway per load replay), each given - // this warn sink so a throwing tool presenter is logged, not propagated. + // Presenter failures are logged and contained per session or replay. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) - // TODO(derive-acp-session-id): derive an event's id from agent.session and - // verify sessions.get(id)?.agent === agent; then remove this reverse map and - // SessionRecord.sessionId, whose sole read duplicates the same identity. - // 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 forward record and weak reverse entry are installed together; removing - // the record releases its strong Agent reference, so the WeakMap entry expires. + // TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map. + // Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together. + // Dropping the forward record lets the weak reverse entry expire. 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. + // Reserve ids across asynchronous resume; distinct ids still load concurrently. 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. + // Post-await checks prevent a closing bridge from publishing resumed sessions. 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. + // Connection-level capability copied into each new session record. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -548,17 +434,9 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- The ACP Agent method surface ----------------------------------------- /** - * The session config options this composition can honor: ONE `Mode` - * select over the composed preset table (`ctx.permission` — the product - * layer bundling the sandbox-mode and approval-policy knobs), its current - * value folded from the AGENT'S OWN session log (the log is the - * per-session store, so a `session/load` reports a resumed session's - * preset with no catch-up machinery), overlaid with the record's - * not-yet-anchored pending switch (see - * {@link SessionRecord.pendingSwitches}). Capability-gated like every - * advertised lever: no preset service composed, no options — read - * opportunistically so this bridge keeps working in compositions without - * it. + * Build the single Permissions option when `ctx.permission` is composed. + * Its value comes from the session log, overlaid by an unanchored idle + * switch, so `session/load` needs no catch-up state. */ const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => { const presets = ctx.get('permission') @@ -567,15 +445,13 @@ export function apply(ctx: Context, config: AcpConfig): void { return [{ id: 'permission', name: 'Permissions', - description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', + description: 'Sets this session\'s sandbox and approval behavior.', category: 'mode', type: 'select', currentValue, options: [ ...presets.names.map((name: string) => presets.optionOf(name)), - // The derived not-a-preset state: visible exactly while it IS the - // current value (a knob state outside the table), switchable FROM, - // never a target — set() below rejects it like any unknown name. + // `custom` is offered only as the current-value echo, never as a target. ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], ], }] @@ -584,7 +460,7 @@ export function apply(ctx: Context, config: AcpConfig): void { /** * Whether the session's log currently has an open turn — the last boundary * event is a `turn/start`. Decides whether a config switch may append NOW - * (enclosed) or must wait for the next turn (see + * (enclosed) or must wait for the next prompt submission (see * {@link SessionRecord.pendingSwitches}). Read from the LOG, not * `agent.status`: status stays `running` across the gap between two queued * turns, where a bare append would still land outside any turn. @@ -600,10 +476,8 @@ export function apply(ctx: Context, config: AcpConfig): void { } /** - * Anchor a record's pending switches into its (just-opened) turn, last - * write per knob — skipping a value the session already effectively has, - * so a net-zero idle flip-flop anchors NOTHING (the log records switches, - * not select clicks). + * Anchor a pending preset in the open turn. `PermissionService.set()` skips + * net-zero changes, so the log records switches rather than select clicks. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches @@ -611,20 +485,16 @@ export function apply(ctx: Context, config: AcpConfig): void { if (pending.preset === undefined) return const presets = ctx.get('permission') /* v8 ignore next -- a pending preset exists only if the service answered the - switch; it cannot unmount between that and the next turn in any composition. */ + switch; a valid composition cannot unmount it before anchoring. */ if (presets === undefined) return presets.set(rec.agent.session, pending.preset) } - // 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 on the next prompt submission: its turn is open, but + // request assembly has not begun. This handler runs outside log emission, so + // invariants and persistence observe the events in log order; the first flush + // clears pending state. Promptless injection turns leave the switch pending, + // with no request or execution under stale settings. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { const sessionId = bySession.get(agent) const rec = sessionId === undefined ? undefined : sessions.get(sessionId) @@ -677,8 +547,8 @@ 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 + // Creation 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. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC @@ -860,27 +730,17 @@ 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. + // Open-turn switches append immediately; idle switches wait for the + // next prompt-submit. Only values advertised by this composition are + // accepted, and the session log remains the durable store. switch (params.configId) { case 'permission': { const presets = ctx.get('permission') if (presets === undefined) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - // A no-op switch (the value the session already shows — pending, - // else derived) is acknowledged FIRST and records nothing: - // clients re-push current selections on session start, and the - // derived 'custom' current is only ever valid as such an echo. + // Clients may re-send the current selection on session start. Accept + // that echo without logging; this is the only valid `custom` request. const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events) if (params.value === current) break if (!presets.names.includes(params.value)) { @@ -1121,12 +981,8 @@ export function streamSessionEventUpdate( } /** - * Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires - * `content` + `priority` + `status`, but a {@link TodoItem} carries no priority, - * so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the - * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole - * plan on each `plan` update, matching the harness's whole-list-replace - * semantics, so no per-entry diffing is needed. + * Map a whole harness todo list to an ACP plan, assigning medium priority. + * Statuses map directly and ACP replaces its whole plan on each update. * @param todos - the harness todo list (the whole list, not a diff). * @returns the ACP plan body, one entry per todo. */ @@ -1134,14 +990,7 @@ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } } -/** - * Per-connection terminal-rendering context threaded into - * {@link streamSessionEventUpdate}: whether the client advertised the - * `_meta.terminal_output` capability, and the session's workspace cwd (the - * default terminal-card header when a tool doesn't supply its own). Kept out of - * the pure translator's required params so the no-capability / no-presenter - * tests stay terse. - */ +/** Terminal-card capability and workspace context for event rendering. */ export interface TerminalRendering { enabled: boolean /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */ @@ -1152,59 +1001,31 @@ 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. + * Resolve tool-owned call/result views with generic fallbacks. Per-session + * call-id state supplies the tool name and arguments omitted from result events. + * Each entry is consumed by its result; any remainder dies with the session. */ export class ToolPresenter { private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. - * @param onError invoked when a tool's `presentCall`/`presentResult` THROWS; - * the presenter swallows the error and falls back to the generic - * presentation so a buggy display callback can never fail a live turn or a - * `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the - * boundary"). Defaults to a no-op for callers that don't supply a logger. + * @param onError receives contained presenter failures before generic fallback. */ constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, - /** - * The agent whose view resolves tool presentations: a scoped/shadowed - * tool presents with ITS OWN presentCall/presentResult — the same - * definition that executed — not a same-named global's. Absent (a replay - * with no live agent) the global view presents. - */ + /** Agent scope for tool lookup; absent during replay without a live agent. */ private readonly agent?: Agent, ) {} /** - * Pending-state render intent for a `tool/call`; remembers `(name, args, card)` - * for the matching result. + * Resolve a pending call and remember its state for the matching result. * @param callId - the call id the matching `tool/result` will look up. * @param name - the tool name, resolved against the registry for `presentCall`. * @param argsJson - the raw arguments JSON from the event; parsed for the view * (a non-JSON string is surfaced raw). - * @returns the tool-owned view, or the generic fallback (title = tool name, - * kind `other`, parsed args as raw input) when the tool defines none or threw. + * @returns the tool-owned view, or a generic parsed-input fallback. */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) @@ -1226,16 +1047,12 @@ export class ToolPresenter { } /** - * 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. + * Resolve a completed result and consume its remembered call state. + * @param callId - matching call id; unknown or late ids use 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. + * @returns the normalized tool-owned view, or a raw-content generic fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) @@ -1305,25 +1122,11 @@ type AcpToolCallContent = | { type: 'diff'; path: string; oldText: string | null; newText: string } | { 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 an in-workspace file path in a card title; keep target paths raw. */ 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). + // Reject an empty relative path or a leading parent-directory segment. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index fb39b2a0de..f914004e8b 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -1,10 +1,6 @@ /** - * Session config options over the bridge: ONE user-facing `Permissions` - * select (`ctx.permission`'s preset table — each choice bundles a sandbox - * mode and an approval policy), its current value folded from each session's - * own log, switching via `session/set_config_option` (the preset event plus - * its knob write-throughs — the log is the store), and a resumed session - * reporting its preset back on `session/load` with no catch-up machinery. + * Exercises the bridge's per-session Permissions option: validation, idle + * turn anchoring, isolation, and persistence through `session/load`. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -20,12 +16,8 @@ import PermissionService from '@deepseek-ai/dsh-permission' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' /** - * The REAL local executor reporting a confining default — `sandboxMode` is - * the documented capability override point (`dsh-bash-sandbox` overrides it - * the same way), so the bridge sees exactly what a sandboxing composition - * advertises without this suite dragging in a kernel sandbox stack. It - * reports `workspace-write`: the shipped preset's bundle, which - * the permission service validates the composition defaults against. + * Advertises the real executor through the `sandboxMode` capability without + * loading a kernel sandbox, which these bridge tests do not exercise. */ class SandboxedLocalExecutor extends LocalBashExecutor { override get sandboxMode(): SandboxMode { @@ -33,18 +25,17 @@ class SandboxedLocalExecutor extends LocalBashExecutor { } } -/** The exact option payload the bridge advertises (pinned verbatim). */ function permissionOption(currentValue: string): object { return { id: 'permission', name: 'Permissions', - description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', + description: 'Sets this session\'s sandbox and approval behavior.', category: 'mode', type: 'select', currentValue, options: [ - { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' }, - { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' }, + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, ], } } @@ -62,11 +53,9 @@ describe('acp bridge — session config options', () => { await rm(storageDir, { recursive: true, force: true }) }) - /** A harness composing the full preset stack (confining executor + approval seam + permission presets). */ async function presetStack(options: { script?: NonNullable[0]>['script'] } = {}): Promise { const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} }) - // The dev invariants police turn-enclosure: an idle switch that appended - // outside a turn would throw right here in the suite, not in production. + // Make an out-of-turn switch fail in this suite. await harness.ctx.plugin(Invariants) await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) await harness.ctx.plugin(ApprovalService) @@ -90,14 +79,13 @@ describe('acp bridge — session config options', () => { expect(res.configOptions).toEqual([permissionOption('workspace-write')]) }) - it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => { + it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) expect(after.configOptions).toEqual([permissionOption('danger-full-access')]) - // Idle: nothing in the log yet — turn-enclosure forbids a bare append. const session = h.ctx.agents.list()[0]?.session expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) @@ -112,7 +100,7 @@ describe('acp bridge — session config options', () => { expect(anchored).toBeGreaterThan(turnStart) }) - it('an idle flip-flop anchors as ONE switch (last write wins)', async () => { + it('an idle flip-flop anchors as one switch (last write wins)', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) @@ -121,13 +109,12 @@ describe('acp bridge — session config options', () => { await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1) - // Between turns (a closed turn in the log) a switch still pends — the - // enclosure fold walks past the turn/end — and anchors with the NEXT turn. + // A closed turn does not make a later idle switch appendable. await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' }) expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1) }) - it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => { + it('a net-zero idle flip-flop anchors nothing (switches are recorded, select clicks are not)', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) @@ -177,7 +164,7 @@ describe('acp bridge — session config options', () => { await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' })) .rejects.toThrow(/unknown config option/) - // `permission` exists as a concept but THIS composition never advertised it. + // This composition never advertised `permission`. await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })) .rejects.toThrow(/unknown permission value/) await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true })) @@ -196,10 +183,8 @@ describe('acp bridge — session config options', () => { const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' }) - // B sees the composition default, not A's pending switch... const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' }) expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')]) - // ...and A keeps its own state, untouched by B's. const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' }) expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')]) }) @@ -207,22 +192,17 @@ describe('acp bridge — session config options', () => { it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => { h = await presetStack() const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - // Drift a knob out from under the table (a plugin writing the knob - // directly — the raw setters remain public mechanism), inside its own - // turn: the dev invariants enforce turn-enclosure here too. + // Simulate a plugin calling the public knob setter inside a valid turn. const agent = h.ctx.agents.list()[0] if (agent === undefined) throw new Error('expected an agent') agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.session.append('bash/sandbox-mode', { mode: 'read-only' }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // The echo of the derived current is a no-op, not an unknown-value error… const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }) const option = echo.configOptions?.[0] expect(option).toMatchObject({ currentValue: 'custom' }) if (option === undefined || !('options' in option)) throw new Error('expected a select option') expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom']) - // …while custom as a TARGET from a real preset stays rejected: switching - // away is ordinary, and the custom entry disappears from the options. const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) const afterOption = away.configOptions?.[0] expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' }) diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index f02a7b0649..e63658f12a 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -24,22 +24,18 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Dispose the whole context. The bridge's teardown must abort the agent and - // AWAIT whenIdle() — so right after dispose resolves, the agent is settled - // (not still running). Proves disposal waited, not just requested. + // Teardown must abort and await the loop: once it resolves the agent is settled, and the + // hanging prompt itself completes as cancelled rather than remaining pending. await harness.ctx.fiber.dispose() expect(agent.status).not.toBe('running') - // The in-flight prompt settled (cancelled) rather than hanging forever. const res = await promptDone expect(res.stopReason).toBe('cancelled') }) 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. + // Unload only the bridge while transport and shared services remain live. Its closed guard must + // reject late creation before an orphan agent can enter the registry. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -51,14 +47,8 @@ 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 traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only + // disposal must therefore reclaim the agent even while agent-loop itself remains mounted. 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 +60,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. + // Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection + // shape—proves a late request did not create an undriveable agent. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -85,35 +73,23 @@ 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. + // Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would + // be swallowed while a registered session survived without a client. 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: [] }) const agent = harness.ctx.agents.get(AgentId(sessionId))! - // Start a prompt that hangs in the model stream. The prompt RPC will never - // return (its transport is severed), so do not await it. + // The transport will close before this hanging RPC settles. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Sever the transport — the bridge's conn.closed teardown runs and drives the - // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() - // 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 the same memoized bridge teardown without removing root services. It must finish the + // AgentHandle teardown and remove both registry records, not just stop the loop. await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() @@ -121,10 +97,8 @@ 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). + // Transport close and fiber disposal can race. Both must await one memoized teardown; a guard + // based only on record removal could let the second caller return while the first still drains. 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: [] }) @@ -133,11 +107,9 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Fire both teardown paths without awaiting the first, then await both. const close = harness.closeClientTransport() const dispose = harness.ctx.fiber.dispose() await Promise.all([close, dispose]) - // After BOTH settle, the agent has fully drained (not still running). expect(agent.status).not.toBe('running') }) @@ -157,14 +129,8 @@ 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 store observer → `session/event`), and only - // THEN remove its publication hooks and session entry. 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. + // AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks, + // then detaches the session. Reloading verifies that order from durable state. 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: [] }) @@ -172,12 +138,9 @@ describe('acp bridge — disposal & HMR safety', () => { const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) - // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() - // Re-load the session from disk: every live event (incl. the closing - // turn/end) was flushed before the session was detached. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) expect(reloaded.events.length).toBe(liveEvents) const last = reloaded.events.at(-1)! @@ -186,18 +149,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 the store-owned publication hooks are 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. + // Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find + // that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last. 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: [] }) @@ -205,16 +158,11 @@ describe('acp bridge — disposal & HMR safety', () => { void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // The turn is OPEN in the log (turn/start appended, no turn/end yet). const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length - // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered - // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() - // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not - // self-report) — NOT a crash-recovery `interrupted` substitute. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end') expect(persistedTurnEnds.length).toBe(openTurnEnds + 1) @@ -223,11 +171,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. + // A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully + // published, which guards against context-wide teardown. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, @@ -239,11 +184,9 @@ describe('acp bridge — disposal & HMR safety', () => { expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) await handleA.dispose() - // A is gone — unregistered AND its session removed from the store. expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') - // B is wholly unaffected. expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') @@ -251,14 +194,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 its publication hooks 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. + // Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or + // it would skip later session detach, leaking publication hooks and creating a durability hole. 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({ @@ -268,7 +205,6 @@ describe('acp bridge — disposal & HMR safety', () => { await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() - // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran @@ -276,18 +212,14 @@ 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 Cordis effect disposer is single-shot and would let a second call return after its epoch + // clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence. 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' }, }) - // Drive a turn that hangs in the model stream, so the loop is mid-turn when - // disposed — its exit runs a final session/flush we can gate to hold the - // teardown observably in-flight. + // A hanging turn makes disposal produce a final flush; gate it so the second call arrives while + // teardown is observably in flight. handle.agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') @@ -295,22 +227,18 @@ describe('acp bridge — disposal & HMR safety', () => { const flushGate = new Promise((resolve) => { releaseFlush = resolve }) harness.ctx.on('session/flush', () => flushGate) - // First dispose enters teardown (aborts the hanging step) and blocks in the - // gated final flush. const first = handle.dispose() let firstSettled = false void first.then(() => { firstSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(firstSettled).toBe(false) - // Second dispose MUST await the same in-flight teardown, not resolve early. const second = handle.dispose() let secondSettled = false void second.then(() => { secondSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(secondSettled).toBe(false) // memoized: still pending with the first - // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index a24c7aa145..440120943f 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -1,12 +1,7 @@ /** - * 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 non-spec fixture that mounts the full in-memory agent/persistence stack and connects the + * ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an + * editor without a subprocess or stdio. */ import { Context } from 'cordis' @@ -218,13 +213,10 @@ 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.) Holding the c2a + // writer lets tests EOF the agent reader and simulate editor disconnect. const a2c = new TransformStream() const c2a = new TransformStream() const c2aWriter = c2a.writable.getWriter() @@ -253,11 +245,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 +271,19 @@ 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". + // Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no + // model and must survive the object spread. 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)` on the ungated root. Later JSON-RPC callbacks run + // outside apply's injection scope, matching production and exposing missing-inject failures. 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). The returned fiber permits ACP-only + // disposal while root services remain live for HMR assertions. 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..f57767fb38 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -58,12 +58,8 @@ 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"). + // Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs + // call and result in log order so replay uses the shipping tool's same cards as live streaming. live = await makeBridgeHarness({ storageDir, withBash: true, @@ -95,10 +91,8 @@ 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 persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the + // current plan, not just the tool transcript. live = await makeBridgeHarness({ storageDir, withTodo: true, @@ -169,11 +163,8 @@ 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. + // Stall persistence so transport closes while resume is pending. Whether the SDK rejects first + // or the bridge's post-await guard fires, no agent may survive for the dead connection. 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 +189,9 @@ 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. Resume must retain the header cwd and route bash there rather than reject the + // mismatch or substitute the server cwd. loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ @@ -237,9 +227,8 @@ 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/external log without `header.cwd` must be rejected; the request cwd does not override + // it, and accepting would let bash silently fall back to the server launch directory. 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..af661d0eed 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -1,17 +1,9 @@ /** - * 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 legal update variants, call-before-result order + * per tool id, and deterministic event-to-update translation. Keeping this pure makes live and + * replay equivalence deterministic rather than a timing property. */ 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 0a9cf3e82e..2afa49e1d4 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -297,10 +297,9 @@ 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"). The + // presenter reports the error and falls back to generic rendering. const boom: ToolDefinition = { name: 'boom', description: 'b', @@ -630,12 +629,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. The real tool is required because its result metadata is the contract. it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => { const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) @@ -672,11 +669,10 @@ 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. Diff and location + // paths remain absolute so the editor can open the real file. 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' }) @@ -698,11 +694,8 @@ 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. + // Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic + // tool is required to cover both absent-title and empty-content result branches. const emptyDiffTool: ToolDefinition = { name: 'writer', description: 'writes a file', @@ -728,11 +721,9 @@ 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 location/diff paths raw. Use real fs tools + // and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd. function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] { const presenter = new ToolPresenter(ctx.tools) const out: SessionNotification['update'][] = [] @@ -775,10 +766,9 @@ 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. Segment-aware guarding must relativize it, + // matching targets under `cwd + sep` in the reference adapter. 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 f559ca36d9..78591ffa76 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -124,11 +124,9 @@ 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). + // With terminal output advertised, a real bash call emits description then terminal content + // plus cwd metadata; its result uses terminal output/exit metadata and omits text that would + // clobber the card. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -164,11 +162,8 @@ 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. + // Create the session with terminal support, then disable it connection-wide. The session's + // snapshot must keep call and result rendering consistent instead of re-reading changed state. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -324,11 +319,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. + // JSON-RPC timing normally makes this a running mid-step cancellation; pre-step dropping is + // covered in agent-loop. Here the prompt must settle cancelled, return idle, and clear queued + // work so the scripted second response cannot leak into another turn. 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' }] }) @@ -337,18 +330,13 @@ describe('acp bridge — turn outcomes', () => { expect(res.stopReason).toBe('cancelled') const agent = harness.ctx.agents.get(AgentId(sessionId))! await agent.whenIdle() - // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so - // no second turn was batched or leaked. (A best-effort abort that left queued - // work could have started a second turn.) const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length expect(turnStarts).toBeLessThanOrEqual(1) }) 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 bridge settles cancel synchronously, so exercise the production cancel→prompt race with + // no `whenIdle()`. An idle cancel must not mark or drop the following prompt. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) // Cancel while idle (no prompt in flight) — a no-op. @@ -364,9 +352,8 @@ describe('acp bridge — turn outcomes', () => { }) it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => { - // Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence - // (the synchronous-settle path). The new prompt must run — the cancel marker - // must not leak onto it. + // Cancel a running turn and immediately send another prompt without awaiting quiescence. The + // cancellation marker belongs only to the first turn and must not drop the next request. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] }) const sessionId = await newSession(harness) const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] }) @@ -384,10 +371,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. + // Cancellation frees A's slot before its aborted turn/end is appended. Send B in that window; + // correlation by turn number must prevent A's late closer from settling B as cancelled. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] }) const sessionId = await newSession(harness) @@ -396,8 +381,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) expect((await a).stopReason).toBe('cancelled') - // Immediately send B; its turn (2) is distinct from A's (1). If A's late - // turn/end leaked onto B, B would settle 'cancelled' instead of 'end_turn'. + // B owns the later turn and must complete on its own turn/end. const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] }) expect(b.stopReason).toBe('end_turn') const text = harness.updates diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 8f3392e913..a519b3cf70 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. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. * @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. */ @@ -52,12 +30,8 @@ export function resolveConfigPath( } /** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in - * `dir` (Node native `process.loadEnvFile`). An absent file is fine — the - * environment may already carry the variables; the leaf `cordis.yml` reads - * them via the `!!js` tag. A present-but-unreadable `.env` is a real - * misconfiguration: surface it via `warn` (one line, default stderr) rather - * than silently running with the wrong environment. + * Load the optional gitignored `.env` from `dir`. Missing files fall back to the + * ambient environment; other read failures are reported through `warn`. * @param binName - the diagnostic prefix on the warn line. * @param dir - the directory whose `.env` to load. * @param warn - sink for the one-line misconfiguration diagnostic. @@ -88,15 +62,9 @@ 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). + * Install before boot to turn a late unhandled plugin-init rejection into one + * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP; + * the returned function removes the handler. * @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 +79,9 @@ 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, reject entries with no fiber, which indicates a + * swallowed module-import failure. Disabled entries are the only valid + * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. */ @@ -130,27 +94,11 @@ 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. - * - * 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. + * Boot the Loader against `absoluteConfigPath` and return only after the whole + * tree settles. The include uses an absolute file URL while `baseUrl` stays at + * the config directory for its relative imports. A missing fiber rejects here; + * a later init rejection is handled by {@link installFailLoud}. Built bins need + * `--expose-internals` for bare plugin specifiers; relative specifiers do not. * @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/jsonrpc-agent/README.md b/packages/ui/jsonrpc-agent/README.md index e7716f5e42..4ced8d038a 100644 --- a/packages/ui/jsonrpc-agent/README.md +++ b/packages/ui/jsonrpc-agent/README.md @@ -1,20 +1,20 @@ # @deepseek-ai/dsh-jsonrpc-agent -The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from an externally supplied `cordis.yml` and let its [`@deepseek-ai/dsh-jsonrpc`](../jsonrpc/README.md) entry serve SDK clients over newline-delimited JSON-RPC on stdio. Structurally the SDK-runtime sibling of [`acp-agent`](../acp-agent/README.md)'s bin, but bin-only: there is no composition plugin here, because "the plugins that actually start come from the external config" is the SDK runtime's hard semantic — the leaf `cordis.yml` composes the spine, the backends, AND the serving face. This package is the entrypoint of the single-exe distribution (its `lib/bin.js` is what the packaged executable runs) — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. ## Config discovery -Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent `, the human channel). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier. +The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`. -Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server". +A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not designate a server plugin. ## Exit lifecycle -The bin owns the PROCESS-level exits: stdin EOF (the SDK client is gone — an in-flight turn is deliberately cut off, see the risk note in docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) and `SIGTERM` dispose the root context to quiescence and exit 0; `SIGINT` does the same but exits 130. The PROTOCOL-level exit — a `shutdown` JSON-RPC request answered first, then exit 0 — is owned by the `dsh-jsonrpc` plugin, which holds the server and transport; the two paths are individually idempotent and safe to race. +stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race. ## stdout is the protocol -stdout carries only JSON-RPC frames; the bin and the app-boot guards write diagnostics to stderr only, and the booted config must load no stdout logger (see the `dsh-jsonrpc` README). +stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, and the config must omit stdout loggers. ## Model Experience diff --git a/packages/ui/jsonrpc-agent/package.json b/packages/ui/jsonrpc-agent/package.json index aebff4ab3a..bef09ad15f 100644 --- a/packages/ui/jsonrpc-agent/package.json +++ b/packages/ui/jsonrpc-agent/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-jsonrpc-agent", - "description": "JSON-RPC SDK server app bin: boots an externally supplied cordis.yml (DSH_CORDIS_CONFIG or argv, no built-in fallback) whose dsh-jsonrpc entry serves SDK clients over stdio; the single-exe runtime entrypoint", + "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/ui/jsonrpc-agent/src/bin.ts b/packages/ui/jsonrpc-agent/src/bin.ts index a70251a2be..09af1028b9 100644 --- a/packages/ui/jsonrpc-agent/src/bin.ts +++ b/packages/ui/jsonrpc-agent/src/bin.ts @@ -1,28 +1,11 @@ #!/usr/bin/env node /** - * The `dsh-jsonrpc-agent` bin: boot a harness from an externally supplied - * `cordis.yml` whose `@deepseek-ai/dsh-jsonrpc` entry serves SDK clients over - * newline-delimited JSON-RPC on stdio. The shared 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 stdio/ACP bins; this bin - * owns only config discovery and the process-level exit lifecycle: - * - * - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client - * convention, wins) or the `argv[2]` positional path (the human channel, - * for direct launches); an empty value counts as absent. Neither - * given, or the path missing on disk, prints the one-line usage to stderr - * and exits 1. No built-in fallback — the external config IS the deployment - * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - * No `DSH_SNAPSHOT` handling: this - * protocol is not part of the ACP snapshot tier. - * - stdin EOF (the SDK client is gone) and SIGTERM dispose the root context - * to quiescence and exit 0; SIGINT does the same but exits 130. The - * `shutdown` JSON-RPC request's answer-then-exit-0 path is owned by the - * `dsh-jsonrpc` plugin, which holds the server (see its README). - * - * IMPORTANT: stdout is the JSON-RPC channel. Diagnostics go to STDERR only (a - * stray stdout write corrupts the protocol frames), which the app-boot guards - * already honor. + * Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves + * newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`; + * empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode. + * App-boot owns env loading, Loader guards, and settled-tree startup. + * stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130. + * Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames. * * @module @deepseek-ai/dsh-jsonrpc-agent/bin */ @@ -32,17 +15,11 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/ const NAME = 'dsh-jsonrpc-agent' -/* v8 ignore start -- thin self-executing composition over the unit-tested - dsh-app-boot helpers; the serving lifecycle it boots is unit-tested in - @deepseek-ai/dsh-jsonrpc, and the composed artifact is exercised by the - single-exe acceptance drive (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) */ +/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ installFailLoud(NAME) loadEnv(NAME) -// Env wins over the positional argument; an empty value on either channel -// counts as absent. There is deliberately NO default `./cordis.yml`: "the -// plugins that actually start come from an explicit external config" is a -// hard semantic of the SDK runtime. +// Env wins over argv; empty values are absent. External config defines the deployment. const fromEnv = process.env['DSH_CORDIS_CONFIG'] const fromArgv = process.argv[2] const requested = fromEnv !== undefined && fromEnv !== '' diff --git a/packages/ui/jsonrpc-agent/src/index.ts b/packages/ui/jsonrpc-agent/src/index.ts index 39a2206dca..032644a7c9 100644 --- a/packages/ui/jsonrpc-agent/src/index.ts +++ b/packages/ui/jsonrpc-agent/src/index.ts @@ -1,12 +1,7 @@ /** - * The `dsh-jsonrpc-agent` app package IS its bin (see `./bin.ts`): config - * discovery plus the process-level exit lifecycle around a booted - * `cordis.yml`. This module deliberately exports nothing — unlike the - * stdio/ACP app packages there is no composition plugin here, because the - * serving face is the {@link @deepseek-ai/dsh-jsonrpc} plugin the external - * config loads like any other entry (which plugins actually start is the - * config's decision, the hard semantic of the SDK runtime; see - * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns + * process exit. This module exports no composition plugin; the config chooses + * whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin. * * @module @deepseek-ai/dsh-jsonrpc-agent */ diff --git a/packages/ui/jsonrpc-agent/tsdown.config.ts b/packages/ui/jsonrpc-agent/tsdown.config.ts index 5b09ae2704..aaa860edd0 100644 --- a/packages/ui/jsonrpc-agent/tsdown.config.ts +++ b/packages/ui/jsonrpc-agent/tsdown.config.ts @@ -1,11 +1,7 @@ import { defineConfig } from 'tsdown' /** - * jsonrpc-agent ships TWO entries: the doc-only module (`index`) and the CLI - * `bin` (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. - * The root tsdown builds only `lib/types/index.js`, so this override adds - * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), - * matching every package. + * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. */ export default defineConfig({ entry: ['lib/types/index.js', 'lib/types/bin.js'], diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 5d45fd1e42..46c82aa05b 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,26 +1,26 @@ # @deepseek-ai/dsh-jsonrpc -The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize` → `session/prompt` → `shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, [`jsonrpc-agent`](../jsonrpc-agent/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the single-exe distribution plan — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). +Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../jsonrpc-agent/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design. ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`) and demuxes `subagent/end` through the registry. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId` on `session/prompt` and demuxes `subagent/end` through the registry. If `initialize.model` lacks a registered adapter, it mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`; a config-registered adapter wins. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. ## Config -No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`. +No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`. ## stdout is the protocol -The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr. +stdout carries only JSON-RPC frames. The loading config must omit stdout loggers; diagnostics go to stderr. ## Shutdown and exit semantics -The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process. +A `shutdown` request flushes its response, disposes the plugin fiber, then exits 0. Disposal idempotently shuts down every SDK-created agent to quiescence, detaches subscriptions, and closes the transport. Bare fiber disposal only stops serving; it does not exit. The app bin owns root disposal for stdin EOF (0), SIGTERM (0), and SIGINT (130). ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. Each session permits one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and deployment persona remain in `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index be19ab7217..94355fe570 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-jsonrpc", - "description": "Stdio JSON-RPC SDK server plugin: serves HarnessSdkServer over newline-delimited JSON-RPC on the process stdio, letting an out-of-process SDK client (e.g. the Python SDK) drive DeepSeek Harness agents", + "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 624c8db824..29fb1aeff8 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -1,30 +1,10 @@ /** - * The SDK-facing stdio JSON-RPC server plugin: mounting it wires a - * {@link JsonRpcLineTransport} over the process stdio and serves - * {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`, - * plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK - * client (e.g. the Python `deepseek_harness` package). The structured - * SDK-client analogue of the `acp` bridge: a client-driver plugin over - * `ctx.agents`, not a loop change and not a capability seam. Which process - * actually serves this protocol is a `cordis.yml` decision — the tree that - * loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such - * a tree for the single-exe distribution; see - * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - * - * stdout is the protocol: this plugin must run in a tree 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. - * - * Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the - * `shutdown` request answers first, then the plugin disposes its own fiber and - * exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM, - * SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the - * whole root context. - * - * 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 collapse the module to the bare `apply` - * and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001). + * SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides + * whether to load it; see the single-executable RFC and package README. + * Stdout is reserved for protocol frames, so the tree must not load a stdout logger. + * This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin + * owns EOF and signal exits. Keep named plugin exports with no default export so + * Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`. * * @module @deepseek-ai/dsh-jsonrpc */ @@ -39,65 +19,29 @@ export * from './server.ts' export * from './transport.ts' export const name = 'jsonrpc' -// The server programs against the agent factory only: `agents` is read on -// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM -// seam is deliberately NOT injected — `initialize` reads it opportunistically -// via `ctx.get('llm')` (the topology-independent lookup for a non-injected -// service, per packages/AGENTS.md) to decide whether to lazily mount the -// DeepSeek adapter for the requested model. +// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). export const inject = ['agents'] -/** - * Plugin config. Every field is a runtime-only test seam — none is part of the - * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` - * (production always serves the process stdio and exits via `process.exit`). - */ +/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ export interface JsonRpcConfig { - /** - * Transport input override. Production omits this (the plugin reads - * `process.stdin`); tests inject an in-memory `Readable` to drive the server - * without a subprocess. - */ + /** Transport input override; production uses `process.stdin`. */ input?: Readable - /** - * Transport output override. Production omits this (the plugin writes - * `process.stdout` — the protocol channel); tests inject an in-memory - * `Writable` to capture frames. - */ + /** Transport output override; production uses `process.stdout`. */ output?: Writable - /** - * Process-exit override for the `shutdown` request path. Production omits - * this (`process.exit`); tests inject a recorder so a driven shutdown does - * not kill the test process. - */ + /** Process-exit override; production uses `process.exit`. */ exit?: (code: number) => void } export const Config: Schema = Schema.object({}) /** - * Mount the SDK server on the process stdio: build the line transport and - * {@link HarnessSdkServer}, dispatch incoming requests, and start reading - * frames. Disposal is an effect: disposing this plugin's fiber runs - * `server.shutdown()` (disposes every SDK-created agent to quiescence and - * detaches the event subscriptions) and `transport.close()`. - * - * The `shutdown` request's process-exit semantics live HERE, because the - * plugin owns the server and transport: the request is answered first, an - * explicit output-write barrier confirms the response frame flushed, then the - * plugin disposes its - * OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the - * request's `server.shutdown()` already brought every SDK-created agent to - * quiescence (their session logs are flushed by the awaited agent-handle - * disposes), the fiber's effect disposer re-runs the idempotent shutdown and - * closes the transport, and the process exit that follows IS the teardown of - * the rest of the tree (the bin's EOF/signal handlers own root-context - * disposal for the process-level exits). + * Serve SDK requests over the configured streams. Effect disposal shuts down + * SDK-created agents and closes the transport. A `shutdown` response is flushed + * before this plugin's fiber is disposed and the process exits 0; the app bin + * owns root-context disposal for EOF and signals. */ export function apply(ctx: Context, config: JsonRpcConfig): void { - // Capture the fiber handle NOW, during apply(): the shutdown path runs LATER, - // from the transport's read loop, and must dispose exactly this plugin's - // fiber (cf. the injection-scope capture note in the acp bridge). + // The later transport callback must dispose this plugin's fiber, not its ambient context. const fiber = ctx.fiber /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ const input = config.input ?? process.stdin @@ -109,10 +53,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkServer(ctx, transport) - // The shutdown-request exit path, exactly once (a second `shutdown` frame - // racing the dispose shares the same task). Flush and disposal failures are - // settled independently: once shutdown was answered, process exit is still - // the honest outcome and neither failure may prevent the next teardown step. + // Share one exit task and attempt flush and disposal independently before exiting. let exitTask: Promise | undefined const disposeAndExit = (): Promise => { exitTask ??= (async () => { @@ -126,9 +67,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { transport.onRequest(async (method, params) => { const result = await server.handleRequest(method, params) if (method === 'shutdown') { - // The transport writes the returned result after this handler resolves. - // Schedule the explicit flush barrier after that write, then dispose this - // plugin's fiber and exit 0 (see apply's doc). + // Run after the handler result is written; the task then flushes, disposes, and exits. setImmediate(() => { void disposeAndExit() }) } return result diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 86af6645f3..91ef655c0c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -1,13 +1,8 @@ /** - * `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin - * serves to out-of-process SDK clients (e.g. the Python `deepseek_harness` - * package). Requests: `initialize` → `session/prompt`* → `shutdown`. - * Notifications pushed to the host: `session.event` (every durable session - * event, verbatim), `session.finished` (per prompt turn settle), - * `subagent.started` / `subagent.finished` (child-session lineage and run - * outcomes). The server owns only the SDK-facing session map — the harness - * itself is the context the plugin mounts in; plugins, persistence, and - * the LLM adapter set all come from the external `cordis.yml`. + * JSON-RPC methods and notifications for SDK clients. Requests are + * `initialize`, repeated `session/prompt`, then `shutdown`; notifications carry + * durable session events, settled turns, and subagent lineage/outcomes. The + * external `cordis.yml` owns plugins, persistence, and the adapter set. * * @module @deepseek-ai/dsh-jsonrpc/server */ @@ -22,7 +17,7 @@ import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' -/** Parameters of the `initialize` request (once per process, before any prompt). */ +/** One-time SDK initialization parameters. */ export interface InitializeParams { /** Working directory recorded on every SDK-created session's header. */ cwd: string @@ -30,7 +25,7 @@ export interface InitializeParams { model: string } -/** Result of the `initialize` request: the server's identity for the SDK handshake. */ +/** SDK handshake result. */ export interface InitializeResult { /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ serverInfo: { name: string; version: string } @@ -47,7 +42,7 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */ +/** Accepted prompt result; the outcome is reported by `session.finished`. */ export interface SessionPromptResult { /** Always `true`; the turn outcome is the paired `session.finished` notification. */ accepted: true @@ -65,11 +60,9 @@ interface SubagentRecord { } /** - * The SDK server over a booted harness context. Constructing it subscribes to - * the context's `session/event`, `session/created`, `agent/created`, and - * `subagent/end` events and forwards them to the host as notifications; the - * subscriptions live until {@link shutdown}. One instance serves one transport - * peer for the process lifetime — there is no re-`initialize`. + * SDK server over one booted harness context and transport peer. Construction + * subscribes to session, agent, and subagent lifecycle events until shutdown; + * reinitialization is unsupported. */ export class HarnessSdkServer { private cwd = process.cwd() @@ -101,8 +94,7 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache agent → session lineage on creation: by the time `subagent/end` - // fires the child agent may already be disposed and gone from the registry. + // Cache lineage before child disposal removes the agent from the registry. this.disposers.push(ctx.on('agent/created', (agent) => { this.subagentSessions.set(String(agent.id), { childSessionId: String(agent.session.id), @@ -132,10 +124,8 @@ export class HarnessSdkServer { } /** - * Handle `initialize`: record the SDK deployment facts (cwd, model) and, when - * no registered adapter serves `params.model`, mount the DeepSeek adapter for - * it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config - * that already registered an adapter for the model wins. + * Record cwd and model, mounting the DeepSeek adapter only when the config + * registered no adapter for that model. * @param params - the SDK handshake parameters. * @returns the server identity for the handshake. */ @@ -149,11 +139,9 @@ export class HarnessSdkServer { } /** - * Handle `session/prompt`: get-or-create the session's agent, send the - * content as the user message, await turn settle (quiescence), then notify - * `session.finished` with the settled turn's outcome. A session accepts at - * most one prompt at a time; an overlapping request fails immediately while - * other sessions remain independent. + * Get or create the session agent, send the prompt, await quiescence, then + * notify `session.finished`. A session accepts one prompt at a time; other + * sessions remain independent. * @param params - the target session id and prompt content. * @returns `{ accepted: true }` after the turn settled. */ @@ -178,10 +166,8 @@ export class HarnessSdkServer { } /** - * Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop - * quiescence), unmount the adapter fiber this server mounted (if any), and - * detach the event subscriptions. The CONTEXT stays up — the bin disposes it - * as part of process exit. + * Dispose SDK-created agents to quiescence, unmount the server-mounted adapter, + * and detach subscriptions. The surrounding context remains running. * @returns an empty object (the JSON-RPC result). */ shutdown(): Promise> { @@ -219,8 +205,8 @@ export class HarnessSdkServer { } /** - * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a - * JSON-RPC error response) on an unknown method. + * Dispatch an incoming request; unknown methods throw for transport conversion + * to a JSON-RPC error response. * @param method - the JSON-RPC method name. * @param params - the raw params object from the wire. * @returns the handler's result, to be serialized as the response. diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/ui/jsonrpc/src/transport.ts index 1f3011f2eb..2e081f6041 100644 --- a/packages/ui/jsonrpc/src/transport.ts +++ b/packages/ui/jsonrpc/src/transport.ts @@ -1,10 +1,7 @@ /** - * Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK - * server's stdio channel). One JSON frame per line; a frame with `id`+`method` - * is an incoming request, `id` alone matches a pending outgoing request, and - * `method` alone is a notification. Malformed lines are ignored (a resilient - * wire reader, not a validator); handler failures become JSON-RPC error - * responses, never a crashed transport. + * Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and + * `method` are requests, `id` alone is a response, and `method` alone is a + * notification. Malformed lines are ignored; handler failures become error frames. * * @module @deepseek-ai/dsh-jsonrpc/transport */ @@ -18,22 +15,18 @@ type RequestHandler = (method: string, params: Record) => Promi type NotificationHandler = (method: string, params: Record) => void /** - * The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs - * to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s. - * Narrow on purpose so tests substitute a recording fake without a stream pair. + * Outbound request and notification surface used by {@link HarnessSdkServer}. */ export interface JsonRpcTransportPeer { /** - * Send a request to the remote peer and await its response. + * Send a request and await its response. * @param method - the JSON-RPC method name. * @param params - the request parameters object. - * @returns the remote peer's `result`; rejects on a JSON-RPC `error` - * response, a write failure, or transport/input closure. + * @returns the result; rejects on an error response, write failure, or closure. */ request(method: string, params: Record): Promise /** - * Send a notification (no response expected). An omitted `params` sends no - * `params` member at all. + * Send a notification; omitted params produce no `params` member. * @param method - the JSON-RPC method name. * @param params - the optional notification parameters object. */ @@ -46,14 +39,10 @@ interface PendingRequest { } /** - * Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair. - * Inert until {@link start} attaches the input listeners; {@link close} - * detaches them and rejects every pending outgoing request (dispose-safe: the - * streams themselves are not destroyed — the caller owns them). Incoming - * requests are dispatched to the single {@link onRequest} handler (a missing - * handler answers `-32601 method not found`; a throwing handler answers - * `-32603` with the message); incoming notifications go to {@link - * onNotification} and are dropped without one. + * Line-delimited endpoint over caller-owned streams. {@link start} attaches + * listeners; {@link close} detaches them and rejects pending requests without + * destroying the streams. Missing request handlers return `-32601`; handler + * failures return `-32603`. Notifications without a handler are dropped. */ export class JsonRpcLineTransport implements JsonRpcTransportPeer { private buffer = '' @@ -78,8 +67,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { } /** - * Detach the input listeners and reject every pending outgoing request with - * "JSON-RPC transport closed". Safe to call without a prior {@link start}. + * Detach listeners and reject pending requests. Safe before {@link start}. */ close(): void { this.input.off('data', this.onData) @@ -89,7 +77,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { } /** - * Install THE handler for incoming requests (a later call replaces it). + * Install the request handler, replacing any prior handler. * @param handler - resolves to the response `result`; a rejection becomes a * `-32603` error response carrying the message. */ @@ -98,7 +86,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { } /** - * Install THE handler for incoming notifications (a later call replaces it). + * Install the notification handler, replacing any prior handler. * @param handler - invoked per notification with the method and normalized * params object. */ @@ -125,9 +113,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { } /** - * Wait until every frame written before this call has reached the output's - * write callback. The empty queued write is a barrier and emits no protocol - * bytes. + * Wait for prior frame write callbacks. The empty barrier emits no bytes. * @returns a promise that settles with the output write callback. */ flush(): Promise { @@ -170,8 +156,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { try { message = JSON.parse(line) } catch { - // Swallows ONLY JSON.parse syntax errors: a malformed wire line is a - // peer bug this resilient reader skips; nothing else runs in the try. + // Only JSON syntax errors reach this catch; malformed peer lines are ignored. return } if (!message || typeof message !== 'object') return diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts index 8f4a504264..9c96e815dc 100644 --- a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -11,21 +11,12 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' /** - * apply()-level lifecycle coverage for the @deepseek-ai/dsh-jsonrpc plugin: - * the plugin is mounted through the REAL namespace mount path — - * `ctx.plugin(jsonrpc, config)` over the module namespace object, exactly what - * the Loader hands cordis after `unwrapExports` (plugin-shape.spec pins that - * identity) — with the runtime-only `input`/`output`/`exit` seams from - * {@link jsonrpc.JsonRpcConfig} replacing the process stdio, so the whole - * pipeline (line transport → HarnessSdkServer → notifications back onto the - * wire) runs in-process. The scenarios pin the plugin's exit-lifecycle split: - * a `shutdown` REQUEST answers first, then disposes the plugin's own fiber and - * calls `exit(0)` exactly once (a racing second `shutdown` must not re-exit); - * a bare fiber dispose (HMR-style unload, no request) only stops serving and - * never touches `exit`. + * Mount the real namespace plugin with in-memory stdio and exit seams. Covers + * the full transport/server path, response-before-exit shutdown exactly once, + * and bare-fiber disposal without process exit. */ -/** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */ +/** One ordered frame, write completion, or exit observation. */ type WireEvent = | { kind: 'frame'; frame: Record } | { kind: 'write-complete'; ids: (string | number)[] } @@ -33,9 +24,9 @@ type WireEvent = interface ApplyHarness { ctx: Context - /** The jsonrpc plugin's own fiber (NOT the root), for the HMR-style dispose scenario. */ + /** The plugin fiber used by the bare-dispose case. */ fiber: Awaited> - /** Every output frame and exit call, in observation order — ordering assertions read this. */ + /** Frames, write completions, and exits in observation order. */ events: WireEvent[] outputErrors: Error[] send(frame: Record): void @@ -46,7 +37,7 @@ interface ApplyHarness { dispose(): Promise } -/** Poll `get` until it yields a value (5s cap) — the output side is fed asynchronously from the transport's read loop. */ +/** Poll asynchronous output for up to five seconds. */ async function waitFor(get: () => T | undefined, description: string): Promise { const deadline = Date.now() + 5000 for (;;) { @@ -57,16 +48,12 @@ async function waitFor(get: () => T | undefined, description: string): Promis } } -/** Let pending microtasks, setImmediate callbacks, and stream events drain — for asserting that something did NOT happen. */ +/** Drain asynchronous work before a negative assertion. */ async function settle(): Promise { await new Promise(resolve => setTimeout(resolve, 25)) } -/** - * Boot a minimal harness context (agent-core bundle + JSONL persistence, the - * server.spec recipe) and mount the jsonrpc plugin on it through the real - * namespace mount path, with in-memory seams standing in for stdio/exit. - */ +/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */ async function mountPlugin( storageDir: string, options: { writeDelayMs?: number; failFlush?: boolean } = {}, @@ -80,9 +67,8 @@ async function mountPlugin( const events: WireEvent[] = [] const outputErrors: Error[] = [] let pendingOutput = '' - // A hand-rolled Writable (not a PassThrough): _write records frames on - // admission and write-complete only when its callback fires, so a delayed - // output proves exit waits for the transport's flush barrier. + // Record frame admission separately from write completion so delayed output + // tests the flush barrier. const output = new Writable({ write(chunk: Buffer, _encoding, callback) { const ids: (string | number)[] = [] @@ -138,7 +124,7 @@ afterEach(async () => { vi.unstubAllEnvs() }) -/** The server.spec mock OpenAI-compatible SSE endpoint, so a prompt turn completes without a real key. */ +/** Keyless SSE endpoint for completing a prompt turn. */ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> { const requests: unknown[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { @@ -206,8 +192,7 @@ describe('dsh-jsonrpc plugin apply', () => { expect(body.model).toBe('dsagent-model') expect(body.messages.at(-1)?.role).toBe('user') - // The server's notify() path rides the SAME transport apply() built: - // session.event / session.finished arrive as id-less frames on output. + // Notifications use the same transport and arrive as id-less frames. const notifications = harness.frames().filter(frame => frame.id === undefined) expect(notifications.some(frame => frame.method === 'session.event')).toBe(true) expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({ @@ -224,9 +209,7 @@ describe('dsh-jsonrpc plugin apply', () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) try { - // Two shutdown frames in ONE chunk: both are dispatched from the same - // read-loop pass, so both setImmediate exit callbacks get scheduled and - // the second must hit the `exiting` guard instead of re-entering. + // One chunk makes the two deferred exit callbacks race. const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' } const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' } harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`) @@ -234,8 +217,7 @@ describe('dsh-jsonrpc plugin apply', () => { await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call') expect(harness.exits()).toEqual([0]) - // Response-then-exit ordering: both response write callbacks and the - // empty flush barrier complete before exit(0), even on delayed output. + // Both response writes and the flush barrier complete before exit. const exitIndex = harness.events.findIndex(event => event.kind === 'exit') const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1') const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2') @@ -250,11 +232,9 @@ describe('dsh-jsonrpc plugin apply', () => { expect(flushComplete).toBeGreaterThan(secondComplete) expect(exitIndex).toBeGreaterThan(flushComplete) - // Idempotent: the racing second shutdown never produces a second exit. await settle() expect(harness.exits()).toEqual([0]) - // The plugin fiber is disposed: the transport reads no further frames. const before = harness.frames().length harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) await settle() @@ -290,8 +270,7 @@ describe('dsh-jsonrpc plugin apply', () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-')) const harness = await mountPlugin(storageDir) try { - // Prove the pipeline is live first (an unknown method still answers, as - // a JSON-RPC error frame — the transport's handler-rejection path). + // Prove the handler-rejection path is live before disposal. harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' }) const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method') expect(error.error).toMatchObject({ @@ -301,8 +280,6 @@ describe('dsh-jsonrpc plugin apply', () => { await harness.fiber.dispose() - // The effect disposer shut the server and closed the transport — later - // frames are never read — and the exit seam was never touched. const before = harness.frames().length harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) await settle() diff --git a/packages/ui/jsonrpc/tests/plugin-shape.spec.ts b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts index 3edbf1514f..97afa7d3ed 100644 --- a/packages/ui/jsonrpc/tests/plugin-shape.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts @@ -3,21 +3,11 @@ import Loader from '@cordisjs/plugin-loader' import * as jsonrpc from '../src/index.ts' /** - * REAL-export-path guard for the @deepseek-ai/dsh-jsonrpc namespace plugin - * (the packages/AGENTS.md red line: a plugin shipped via `cordis.yml` needs a - * test through the real Loader/export path). A hand-built `ctx.plugin({...})` - * mount bypasses `unwrapExports` — the exact path that once collapsed a - * namespace plugin with a stray `export default` and silently dropped its - * `inject` (docs/postmortem/0001) — so this spec drives the REAL - * `Loader.unwrapExports` over the module namespace and asserts the - * `name`/`inject`/`Config`/`apply` shape survives it intact. + * Run the real namespace export through `Loader.unwrapExports`; a stray + * default would discard `name`, `inject`, `Config`, and `apply`. */ describe('dsh-jsonrpc plugin export shape', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // A stray `export default` would make `unwrapExports` (`exports.default ?? - // exports`) collapse the module to the bare default, dropping `inject` — - // the plugin would then throw "cannot get property … without inject" at - // its first `ctx.agents` read. Adding `export default` fails this test. expect('default' in jsonrpc).toBe(false) expect(typeof jsonrpc.apply).toBe('function') diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index e4362c3e3a..bd37890dd1 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-permission -User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary. +User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs. -A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's default tree](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over. +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). ## Model Experience diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 67e623052b..3d61df79bf 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -1,16 +1,9 @@ /** - * User-facing PERMISSION PRESETS: one product-level knob over the two - * mechanism knobs. A preset names a bundle — its sandbox mode - * (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a - * user picks `workspace-write` or `danger-full-access` while the mechanism - * tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event - * records the chosen bundle (the audit fact reverse-mapping cannot recover — - * two presets may share knob values and differ only in composed policy, the - * planned `agent` preset being the standing example), then each knob event - * follows through its own THE-write-path setter, skipping values the session - * already effectively has. Every existing consumer (executor stamping, the - * approval gate, narrators, resume) keeps reading its own knob fold, - * untouched. + * User-facing permission presets over the independent sandbox-mode and + * approval-policy knobs. A switch records the selected preset, then writes + * changed knobs through their canonical setters. Execution, prompt narration, + * and replay keep reading their knob folds. The preset event preserves user + * intent when two presets share a bundle. * * @module dsh-permission */ @@ -32,21 +25,16 @@ declare module 'cordis' { declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's permission preset was switched — log-only (the - * `bash/sandbox-mode` precedent): durable and replayable, never in the - * model transcript. The LAST such event is the session's preset - * ({@link effectivePermissionPreset}); the knob events the switch wrote - * through follow it in the same turn, and they — not this record of the - * user's choice — are what execution reads. + * Records the selected preset as durable, log-only user intent. The knob + * events follow in the same turn and control execution; this event stays + * out of the model transcript and lets {@link effectivePermissionPreset} + * preserve a selection when bundles match. */ 'permission/preset': { preset: string } } } -/** - * One preset's knob bundle — the sandbox mode and approval policy a session - * runs under while the preset is active — plus its presentation. - */ +/** One preset's sandbox/approval bundle and optional client presentation. */ export interface PresetSpec { /** The `bash/sandbox-mode` value the preset writes through. */ sandbox: SandboxMode @@ -69,21 +57,16 @@ export interface PresetOption { } /** - * The derived not-a-preset state: the session's effective knob values match - * no table entry (composition defaults outside the table, or a knob moved - * out from under the last-chosen preset). Never a switch target and never - * an event payload — {@link PermissionService.current} derives it, and the - * presentation layer shows it as a selectable-FROM-only current value. + * Returned when effective knob values match no table entry. Clients may show + * it as the current value, but it is never a switch target or event payload. */ export const CUSTOM_PRESET = 'custom' /** - * The session's permission-preset override: the last `permission/preset` event in the - * log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the preset of the last switch event, or undefined without one. + * Fold the last selected preset from the durable log; replay needs no catch-up + * state. + * @param events - session events in log order; other event types are ignored. + * @returns the last selected preset, or undefined when none was recorded. */ export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { @@ -104,13 +87,9 @@ export interface Config { } /** - * The permission service (`ctx.permission`). Owns the deployment's preset - * table and THE write path for preset switches; presentation layers (the ACP - * bridge's single `Permissions` select) advertise {@link names} and call - * {@link set}. Composing it REQUIRES both mechanism knobs — a confining - * `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no - * table entry is not an error but the derived {@link CUSTOM_PRESET} state: - * shown as the current value, never a switch target. + * Owns the deployment's permission presets and their write path. Requires a + * confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are + * reported as {@link CUSTOM_PRESET}, not an error. */ export class PermissionService extends Service { // Inline schema call: the config catalog walks `static Config` statically. @@ -121,14 +100,13 @@ export class PermissionService extends Service { name: z.string(), description: z.string(), })).default({ - // Keep the user-facing preset names explicit about filesystem reach. 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', - name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.', + name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.', }, 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', - name: 'danger-full-access', description: 'Full file access, no approval prompts.', + name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), }) @@ -158,11 +136,9 @@ export class PermissionService extends Service { } /** - * The preset a session is on right now, derived from the EFFECTIVE knob - * values (fold ?? composition default per knob): the last-chosen preset - * when its bundle still matches (presets may share bundles — the fold - * breaks the tie), else the first table entry that matches, else - * {@link CUSTOM_PRESET} — a mismatch is a state, not an error. + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. * @param events - the session's events in log order. * @returns the effective preset name, or `custom` when nothing matches. */ @@ -182,10 +158,10 @@ export class PermissionService extends Service { } /** - * A preset's knob bundle, for consumers presenting or validating one. + * Resolve a preset's knob bundle. * @param name - the preset name to resolve. - * @returns the bundle; throws on a name outside the table (fails loud — - * an unvalidated caller handed the service an unknown preset). + * @returns the configured bundle. + * @throws when `name` is not in the table. */ resolve(name: string): PresetSpec { const spec = this.presets[name] @@ -196,28 +172,25 @@ export class PermissionService extends Service { } /** - * The select-option presentation of one advertisable value: a table entry - * (label/description from its spec, the raw key standing in for a missing - * label) or the derived {@link CUSTOM_PRESET} with its fixed presentation. + * Build the client option for a table entry or {@link CUSTOM_PRESET}. A + * missing label falls back to the table key. * @param name - a table key, or `custom`. - * @returns the option a client renders; throws on any other name. + * @returns the option a client renders. + * @throws when `name` is neither a table key nor `custom`. */ optionOf(name: string): PresetOption { if (name === CUSTOM_PRESET) { - return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' } + return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' } } const spec = this.resolve(name) return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} } } /** - * THE write path for a preset switch: appends one `permission/preset` event when - * `name` differs from the session's current preset, then writes each knob - * through its own setter, skipping values the session already effectively - * has — a net-zero switch appends nothing (the log records switches, not - * select clicks). + * Record a changed preset, then update each changed knob through its own + * setter. Selecting the effective preset again appends nothing. * @param session - the session the switch belongs to. - * @param name - the preset to switch to (validated via {@link resolve}). + * @param name - the preset to switch to; unknown names throw. */ set(session: Session, name: string): void { const spec = this.resolve(name) diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 2852b2d96b..50b630bfd1 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -6,7 +6,6 @@ import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' -/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */ async function mounted(options: { config?: Config bashDefault?: SandboxMode | undefined @@ -19,7 +18,6 @@ async function mounted(options: { return ctx } -/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */ function freshSession(id: string): Session { return new Session(SessionId(id)) } @@ -55,8 +53,6 @@ describe('PermissionService', () => { const session = freshSession('sess-custom') session.append('bash/sandbox-mode', { mode: 'read-only' }) expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) - // Switching FROM custom is an ordinary write-through; custom itself is - // never a target. ctx.permission.set(session, 'danger-full-access') expect(ctx.permission.current(session.events)).toBe('danger-full-access') expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) @@ -75,10 +71,8 @@ describe('PermissionService', () => { 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' }, } } }) const session = freshSession('sess-tie') - // Same bundle as workspace-write, chosen explicitly: the fold names it. ctx.permission.set(session, 'agentish') expect(ctx.permission.current(session.events)).toBe('agentish') - // A knob drifts: the fold's bundle no longer matches → reverse map wins. session.append('approval/policy', { policy: 'never' }) session.append('bash/sandbox-mode', { mode: 'danger-full-access' }) expect(ctx.permission.current(session.events)).toBe('danger-full-access') @@ -106,9 +100,8 @@ describe('PermissionService', () => { const ctx = await mounted() const session = freshSession('sess-drift') ctx.permission.set(session, 'danger-full-access') - // A knob drifts out from under the preset (a direct setter call, a test - // scenario): the session derives custom, and re-asserting the preset is - // a real switch again — choice re-recorded, only the drifted knob moves. + // Re-selecting from a drifted state records the choice and repairs only + // the changed knob. session.append('bash/sandbox-mode', { mode: 'read-only' }) ctx.permission.set(session, 'danger-full-access') const tail = session.events.slice(4) @@ -125,8 +118,8 @@ describe('PermissionService', () => { it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => { const ctx = await mounted() - expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' }) - expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }) + expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }) + expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }) const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } }) expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' }) expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/) diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index 7056486996..f3eaf5cf34 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -1,14 +1,8 @@ #!/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. - * + * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the + * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in + * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs. * @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 127851eaf5..cd8167d658 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,40 +1,11 @@ /** - * 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. + * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This + * Loader plugin intentionally exposes named exports only; a default export + * would hide its `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-stdio-agent */ diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 8e3c1f467b..60aba12026 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -1,17 +1,7 @@ /** - * 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 into `agent.send()` or + * `steer()`, renders the durable event stream to stdout, and exits piped input + * only after submitted work reaches idle. * @module @deepseek-ai/dsh-stdio-agent/stdio-chat */ @@ -82,15 +72,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 @@ -101,26 +86,15 @@ 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. + // Session ids need not equal agent ids. Seed existing agents before listening + // so a pre-created or HMR-surviving agent still gets its short render label. 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. + // Render the canonical append order from session/event so reasoning state is + // deterministic across chunks and boundaries; there are no agent/* mirrors. let inReasoning = false ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { @@ -163,16 +137,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. + // On piped EOF, exit immediately if no work was submitted. Otherwise wait + // for a real running state followed by idle: sends do not synchronously mark + // running, and several queued lines may share one turn. let stdinClosed = false let disposed = false let submittedWork = false @@ -190,10 +157,8 @@ 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 final output flush; track the timer so re-entry coalesces and HMR + // disposal can cancel it before it exits the replacement process. 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..ddd7ca454e 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -7,31 +7,17 @@ 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). + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and + * require the banner plus echo round-trip. This catches built-only early-exit and config-resolution + * failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis + * bare-plugin loading, matching the demo command. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const stdioBin = join(repoRoot, 'packages/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. +// Symlink each required workspace package by package name so plain Node resolves its built `main`, +// matching an installed dependency rather than tsconfig paths. const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', @@ -50,14 +36,9 @@ async function pkgName(absDir: string): Promise { } /** - * Build a temp consumer dir: `node_modules` with the workspace + vendor packages - * symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml` - * that wires them onto the stdio app. Returns the dir (caller removes it). - * - * `disabledBrokenEntry` appends an entry that points at a non-existent plugin but - * is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by - * design, so it exercises that the fail-loud entry-load guard does NOT mistake a - * valid disabled entry for a failed import. + * Build a temporary external consumer with built workspace/vendor links and a mock-backed config. + * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less + * entries rather than treating them as import failures. */ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise { const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) @@ -153,9 +134,9 @@ 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. The nonexistent path makes that distinction + // observable while the successful round-trip proves boot continued. consumer = await makeConsumer('DISABLED-OK ready.', true) const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') expect(stderr).not.toContain('failed to load') @@ -165,10 +146,8 @@ 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 nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and + // boot's settled-entry guard must turn that state into a clear non-zero failure. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') expect(code).not.toBe(0) @@ -176,9 +155,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..6bbba0e492 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -10,20 +10,10 @@ 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 app composition and config forwarding: console logger, pre-created main agent, + * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the + * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise + * survive namespace collapse while silently losing its schema. */ async function mount(config: stdioAgent.Config): Promise { const ctx = new Context() @@ -163,14 +153,8 @@ 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. + // A default export would make `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. expect('default' in stdioAgent).toBe(false) expect(typeof stdioAgent.apply).toBe('function') diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 93683d0768..f734acfd0d 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -179,11 +179,10 @@ 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. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead + // of falling back to the raw session id. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 02456b9989..571dc0e7b8 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -1,16 +1,14 @@ # @deepseek-ai/dsh-user-approval -User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. +Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md). -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event. +Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision. -The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. +Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice whose header marker distinguishes user changes from operator/config changes. -One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md). - -Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome. +The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). ## Model Experience diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 6b3ef962bb..b9d757964d 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -1,35 +1,6 @@ /** - * 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 request, cancellation, audit, and per-session policy seam. Missing + * answerers fail closed; grants apply only to the requested action. * @module @deepseek-ai/dsh-user-approval */ @@ -51,19 +22,9 @@ 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 a readonly same-process value borrowed from the caller. + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param req - the pending decision (agent, tool identity, reason, signal). * @mode waterfall */ @@ -124,16 +85,8 @@ export function ApprovalRequestId(id: string): ApprovalRequestId { } /** - * The closed outcome vocabulary of one approval request. - * - * - `'allowed-once'` — a one-shot grant for exactly the asked-about action; - * consumed by proceeding, never a durable authorization. - * - `'rejected'` — an answerer (human or policy) said no. - * - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or - * the requesting execution aborted while the question was pending. - * - `'unavailable'` — nobody composed could answer (no listener, none that - * recognizes the agent, or an answerer failed). Callers MUST fail closed on - * it, exactly like `'rejected'` — the two differ only for audit and wording. + * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn + * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' @@ -218,14 +171,10 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean { } /** - * THE write path for a session's approval-policy override: appends exactly - * one `approval/policy` event — the switch IS its event; nothing mutates - * policy state out of band. Takes effect on the session's next ask and next - * prompt assembly (the consumers fold on every read). Rejects a value outside - * {@link APPROVAL_POLICIES} before appending anything. + * Append the sole durable representation of a session policy override. Invalid + * values throw before the log changes; consumers fold the new value on each read. * @param session - the session the override belongs to. - * @param policy - the policy every subsequent ask for this session resolves - * under (until the next switch). + * @param policy - the policy in effect until the next switch. */ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void { if (!APPROVAL_POLICIES.includes(policy)) { @@ -235,13 +184,8 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi } /** - * One concrete permission question. Identifies the action precisely enough - * for an answerer to present it and for the audit events to reconstruct what - * was asked — it deliberately does NOT carry tool arguments: a UI answerer - * attaches the prompt to the already-streamed tool call via `callId` instead - * of re-rendering the call. This is a readonly same-process contract: - * `request()` borrows the request and its `agent` and `signal` capabilities - * directly rather than treating them as serialized input. + * Readonly same-process permission question. `callId` links to an already + * presented tool call, so arguments are not duplicated here. */ export interface ApprovalRequest { /** @@ -278,18 +222,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. + * Approval service that applies session policy before answerers and logs every + * ask/outcome pair to the requesting session. It exposes deterministic policy + * changes to the model through prompt and pre-step notices. */ export class ApprovalService extends Service { static Config: z = z.object({ @@ -301,12 +236,7 @@ export class ApprovalService extends Service { const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) - // 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. + // State only deterministic policy; a marker records the otherwise silent state. ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.section({ name: 'approval:policy', diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index ff0cd5c7d8..f248e5c468 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -436,10 +436,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. This eager grant would bypass a listener-based gate and therefore must never run. const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index a6677b3c65..bd5c5281ca 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -21,11 +21,11 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or the exact `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, and `Error: ` failures while waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, or `Error: `. Waiting for the human adds no tokens. ## Known Limitations and Deferred Work diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 051ced94b7..0e880c52da 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -1,23 +1,9 @@ /** - * 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). - * + * Dependency-free nominal typing for cross-boundary identifiers. Structurally identical runtime + * strings become non-interchangeable statically while retaining ordinary comparison, logging, and + * serialization. Each owning package defines its concrete id and zero-cost factory; brand ids that + * can plausibly be confused across packages, not arbitrary strings. This package exports only the + * erased primitive so an owner need not depend on another capability package. * @module @deepseek-ai/dsh-brand */ diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index ed95a877d3..47c5d87c84 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -1,43 +1,13 @@ /** - * 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. - * + * Shared timeout arithmetic, signal fusion, and classification. The library + * only notifies through abort signals; each capability still owns the mechanism + * that stops its work and translates timeout reasons into public outcomes. * @module @deepseek-ai/dsh-timeout */ /** - * The internal reason attached to a timeout abort so consumers can classify it - * after the fact. It carries the failing `code` (each capability's own string — - * `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed. - * - * It is an INTERNAL classification reason, not a public error: providers - * translate it into their seam-specific error code or result field (via - * {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()` - * yields a fixed `TimeoutError` indistinguishable across timeout kinds; this - * type is identifiable and carries the code/duration. + * Internal abort reason carrying a capability-owned code and elapsed deadline. + * Providers translate it through {@link timeoutOf} before returning to callers. */ export class TimeoutReason extends Error { override name = 'TimeoutReason' @@ -52,16 +22,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, use the backend default, then cap + * it. Supplied values must be positive and finite; zero is not a public + * disable-timeout sentinel. * * @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). + * @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)`. */ export function clampTimeout( @@ -85,23 +54,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, …). + * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is + * the internal no-timer sentinel; the returned disposer clears an armed timer. + * The signal only notifies, so callers must stop their own work. * * @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 +69,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 +86,9 @@ 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 a timeout reason from a reason-bearing object. Supplying `code` + * distinguishes this deadline from a nested upstream deadline; a foreign code + * follows the ordinary cancellation path. * * @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..dd4da3adde 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -171,10 +171,9 @@ 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). `AbortSignal.any` preserves that reason, but scoping `timeoutOf` to the inner code + // must classify it as upstream cancellation rather than the inner capability's timeout. 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..78dde7b79e 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. This module owns its schema, validation, and presentation; + * `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes + * `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting + * signal. A provider timeout remains a backstop for direct seam callers. */ 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..1d6ffdb9a3 100644 --- a/packages/web/tool-web/src/html.ts +++ b/packages/web/tool-web/src/html.ts @@ -1,11 +1,8 @@ /** - * 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-to-readable-text conversion for `web_fetch`, not a full parser. It + * removes non-content elements and tags, decodes common entities, collapses whitespace, and keeps + * basic headings, lists, and links. A richer converter can replace it without changing the seam or + * tool schema. * @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..7096371ed1 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -1,19 +1,8 @@ /** - * 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. - * + * Model-facing `web_search` and `web_fetch` tools over `ctx.web`. This package owns schemas, + * validation, prompt guidance, limits, and presentation, never concrete providers. Enablement + * controls tool registration; an enabled tool remains visible when its provider is unavailable + * and fails with a structured error at execution time. * @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..280c96b5bd 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -1,11 +1,9 @@ /** - * 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. Fetch verifies world effects against loopback HTTP; search + * uses the real Exa provider with only its network boundary stubbed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -159,10 +157,9 @@ 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. A short request hint must therefore + // produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`. 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..1df718b871 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -1,16 +1,8 @@ /** - * 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 guard for an injected namespace plugin. A default export would make + * `unwrapExports` collapse the namespace and drop `inject`, causing access to `ctx.web` to fail. + * Hand-built mounting bypasses that path, so this test unwraps through the real Loader first; see + * postmortem 0001. */ import { describe, expect, it } from 'vitest' @@ -41,7 +33,7 @@ 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. + // Mounting the collapsed shape would throw for missing injection 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/README.md b/packages/web/web-fetch-local/README.md index fc4dd9995e..265efea4f8 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -8,7 +8,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. -The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed. +The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`. + +A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed. ## Transport hygiene @@ -35,7 +37,7 @@ The numeric limits are validated at plugin construction: every cap except `maxRe ## Model Experience -Indirectly, through `dsh-tool-web`, which renders this provider's `maxBodyChars`-bounded decoded text or markdown-shaped HTML under the exact fetch header and its stable failures under `Error: ` into retained tool history while redirects, headers, and transport mechanics remain hidden. +Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which places this provider's `maxBodyChars`-bounded decoded text or markdown-shaped HTML under its fetch-result wrapper and retains provider failures while redirects, headers, and transport mechanics remain hidden. ## Known Limitations and Deferred Work diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ed332c4508..69f0509d64 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -1,21 +1,10 @@ /** - * `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. + * Safe HTTP(S) retrieval for `ctx.web`: validates URLs, follows only same-origin redirects, + * enforces time and size limits, classifies and decodes text, and leaves presentation to + * `@deepseek-ai/dsh-tool-web`. Requests carry no browser cookies or ambient credentials. * + * Private-network and SSRF protection is not implemented; do not enable this provider where + * it can reach sensitive internal targets. * @module @deepseek-ai/dsh-web-fetch-local/provider */ @@ -60,11 +49,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 signal stops both the request and body read. The deadline's TimeoutReason later + // distinguishes this provider's timeout from caller or outer-deadline cancellation. using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT') return await this.followAndRead(request.url, d.signal) } @@ -78,11 +64,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 +77,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/README.md b/packages/web/web-search-deepseek/README.md index d68c86f92e..eba4732d7d 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -33,7 +33,11 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: ## Mapping -DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, `publishedAt` ← `page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, and `publishedAt` ← `page_age`. Snippets live separately as URL-keyed `cited_text` entries in a text block's `citations[]`; the provider joins them, leaving `snippet` absent when no excerpt exists. + +Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`. + +Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. ## Model Experience @@ -45,7 +49,7 @@ DeepSeek returns no provider-generated answer surface this provider trusts as `c ### Conversation tool result, indirectly -**What the model sees**: Through `dsh-tool-web`, the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. Provider failures become `Error: DeepSeek search aborted`, `Error: DeepSeek search request failed: `, `Error: DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, or `Error: DeepSeek returned an unprocessable response body: `; HTTP failures pass through their provider message after `Error:`. +**What the model sees**: Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: `, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. **Token effect**: Zero direct conversation tokens from registration. Result tokens scale with returned sources and snippets, then the seam enforces the requested source bound. diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 2c3f0ede3b..9666dde720 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -1,15 +1,7 @@ /** - * `@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. - * + * Register a DeepSeek-backed provider in `ctx.web`. It calls the Anthropic-compatible Messages API + * with native `web_search_20250305`. The provider reuses `DEEPSEEK_API_KEY` but not + * `DEEPSEEK_BASE_URL`, because search and chat-completions use different bases. * @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..c972a0f8c0 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -1,22 +1,8 @@ /** - * `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`. - * + * DeepSeek search through an Anthropic-compatible Messages model call with the native + * `web_search_20250305` server tool. Each search costs a model turn, but returns structured + * result blocks; absence of those blocks is an error rather than a prose-scraping fallback. + * The wire format and native `fetch` client are provider-private and do not use `ctx.llm`. * @module @deepseek-ai/dsh-web-search-deepseek/provider */ @@ -101,19 +87,16 @@ 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. * @returns the normalized result with deduped, snippet-joined sources. + * @throws {@link WebError} when native search produced no result block. */ export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult { const blocks = response.content ?? [] diff --git a/packages/web/web-search-deepseek/src/types.ts b/packages/web/web-search-deepseek/src/types.ts index bd88ed9663..75e05042f0 100644 --- a/packages/web/web-search-deepseek/src/types.ts +++ b/packages/web/web-search-deepseek/src/types.ts @@ -1,16 +1,7 @@ /** - * 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`. - * + * Provider-private wire types for DeepSeek's Anthropic-compatible Messages API. Citeable + * result items and citation excerpts arrive in separate blocks; the provider joins them by + * URL. These types do not create a dependency on `ctx.llm`. * @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..863099f697 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -300,14 +300,8 @@ 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 `unwrapExports` collapse the namespace and drop `inject: ['web']`. + // Drive the real Loader path because hand-built namespace mounting cannot expose that failure. 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/README.md b/packages/web/web-search-exa/README.md index 7562dcf8ff..ad53a94034 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -27,7 +27,7 @@ Exa returns a flat `results[]` and no generated answer, so `content` is omitted. ## Model Experience -Indirectly, through `dsh-tool-web`, which retains this provider's `maxResults`-bounded URLs, titles, first highlights, and publication dates or exact `Error: Exa search aborted`, `Error: Exa search request failed: `, and `Error: Exa returned an unprocessable response body: ` failures while generated answers and provider-private fields remain outside context. +Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which retains this provider's `maxResults`-bounded URLs, titles, first highlights, and publication dates or its exact `Exa search aborted`, `Exa search request failed: `, and `Exa returned an unprocessable response body: ` failures under the consumer's error wrapper while generated answers and provider-private fields remain outside context. ## Known Limitations and Deferred Work diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 48514c07bd..e4eb620aba 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -1,14 +1,8 @@ /** - * `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). It maps the first non-blank highlight to `snippet`, maps + * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content` + * because Exa returns no generated answer. * @module @deepseek-ai/dsh-web-search-exa/provider */ diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index dfad943092..84415c03b4 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -35,7 +35,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ### Conversation tool result, indirectly -**What the model sees**: Through `dsh-tool-web`, the conversation model sees the generated answer plus structured result metadata or URL-only citations. Failures become `Error: Perplexity search aborted`, `Error: Perplexity search request failed: `, or `Error: Perplexity returned an unprocessable response body: `; HTTP failures pass through their provider message after `Error:`. +**What the model sees**: Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees the generated answer plus structured result metadata or URL-only citations. This provider's exact failures are `Perplexity search aborted`, `Perplexity search request failed: `, and `Perplexity returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. **Token effect**: Zero direct conversation tokens from registration. Answer and source tokens are data-dependent, source count is seam-bounded, and the retained result or error is resent until compaction. diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 3f1959549a..f0502786b4 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -1,15 +1,8 @@ /** - * `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 + * Perplexity search over its OpenAI-compatible chat-completions endpoint. The generated answer + * becomes `content`; sources prefer structured `search_results[]` and fall back to URL-only + * `citations[]`. The wire format and native `fetch` client are provider-private and do not use * `ctx.llm`. - * * @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..9e47fda8c6 100644 --- a/packages/web/web-search-perplexity/src/types.ts +++ b/packages/web/web-search-perplexity/src/types.ts @@ -1,13 +1,7 @@ /** - * 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). Results prefer structured `search_results` and fall back to + * URL-only `citations`; the provider-private wire shape does not depend on `ctx.llm`. * @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..1775b0a04a 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -1,17 +1,8 @@ /** - * 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`): registries and provider-selecting execution for search and + * fetch. Duplicate ids are rejected. At execution time, a configured provider must exist and + * be usable; without one, exactly one usable provider is required, so selection never depends + * on registration order. * @module @deepseek-ai/dsh-web */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index f4cda691b8..b43fb010d5 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`). Search and fetch deliberately share one + * seam so provider selection, cancellation, errors, and product configuration have one owner, + * while retaining separate request and result shapes. * @module @deepseek-ai/dsh-web/types */ @@ -162,39 +150,11 @@ 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 with a machine-routable, open-string `code` and chained `cause`. + * Consumers must tolerate provider-specific codes. Shared codes cover unavailable, + * missing, unusable, ambiguous, or duplicate providers, cancellation, and provider failure; + * the local fetch provider additionally distinguishes invalid or blocked URLs, redirects, + * size and timeout limits, and unsupported content types. Tool execution exposes the code in + * structured error metadata. */ 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..33f2939164 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -1,25 +1,12 @@ /** - * 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. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool + * errors, and background collection remains deferred. Presentation is an args-only generic card + * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt + * section rather than deployment persona prose. * @module @deepseek-ai/dsh-tool-workflow */ @@ -184,10 +171,9 @@ 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. The signal also enters the engine directly, but + // this local bridge preserves the tool contract even if an implementation ignores it. 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..5caffca5ac 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -224,13 +224,8 @@ 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. + // The tool and loop await run.result before cleanup, so cancellation must settle a script + // parked on an unowned promise. Exercise that guarantee through the real registry and worker. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 7709a87370..677ca121e6 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -32,7 +32,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide ## Run sequence -`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode uses a data-URL bootstrap that installs the TypeScript transforms inside the worker; built mode passes the sibling CommonJS bundle `lib/worker.cjs` as a filesystem string. CommonJS is required because pkg's VFS Worker hook compiles filesystem-string entries in that format; the same entry also works under ordinary Node resolution. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. +`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each `agent()` call: @@ -89,7 +89,7 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th ### Parent tool result, indirectly -**What the model sees**: Through `dsh-tool-workflow`, success exposes only the materialized final JSON value and child count in that consumer's exact wrapper. An engine failure becomes exactly `Error: workflow run failed: `; stable engine-error shapes include `workflow script does not parse: `, `invalid meta: `, `agent() requires a non-empty prompt string`, `agent() could not start a child: `, `child agent run failed: `, and the exact `parallel()`, `pipeline()`, `phase()`, option, schema, and JSON-boundary validation messages from this package. Intermediate child outputs are available to the script but not the parent model. +**What the model sees**: Through [`dsh-tool-workflow`](../tool-workflow/README.md), success exposes only the materialized final JSON value and child count in that consumer's wrapper. This engine supplies stable errors including `workflow script does not parse: `, `invalid meta: `, `agent() requires a non-empty prompt string`, `agent() could not start a child: `, `child agent run failed: `, and its exact `parallel()`, `pipeline()`, `phase()`, option, schema, and JSON-boundary validation messages. Intermediate child outputs are available to the script but not the parent model. **Token effect**: Zero direct parent tokens from this engine. Final result size is capped by the tool consumer and retained until compaction. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 9ce754c0c6..34d0fce2d8 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -1,43 +1,8 @@ /** - * The host half of one worker-engine run: spawn the Worker, bridge its child - * RPC onto the holder-bound subagent service, 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: receipt of the worker's `result` message, 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). At - * `result` receipt the host snapshots whether caller/signal/dispose - * cancellation is already in flight: an earlier cancellation overrides a - * non-cancelled report; otherwise the report wins before settlement-only child - * cleanup invokes arbitrary provider callbacks. Worker death uses the same - * boundary: it claims `error` (or a previously requested `cancelled`) before - * reaping children, so cleanup callbacks cannot rewrite the outcome. That - * first signal also closes inbound message admission: Node may emit `error`, - * then deliver queued messages, then emit `exit`, but those late messages may - * neither create work nor narrate after settlement. If Result or grace already - * owns the outcome, death preserves it while still cleaning resources; the - * eventual exit performs a final disposal-only sweep without repeating child - * cancellation. - * - * Provider starts and published children are tracked separately. Every start - * receives one shared per-run abort signal; the provider owns partial setup - * until its promise fulfills. If admission closes while a start is pending, - * the signal aborts it; a late fulfillment is disposed without publication to - * the worker. Ready runs enter a callId registry whose memoized disposal is - * shared by graceful worker RPC, public disposal, normal-settlement reap, and - * worker-death cleanup. Quiescence requires both pending starts and published - * children to drain. Lifecycle pairing is host-guaranteed independently: - * every forwarded `agent-start` enters a ledger, and a dead or terminated - * worker's missing `agent-end` is synthesized exactly once as cancelled. On a - * termination path `agentsStarted` reports the host-observed child-start count; - * calls still queued worker-side for a concurrency slot are unknowable. - * + * Host side of one workflow run. The first worker result, unexpected death, or + * cancellation-grace expiry owns settlement and closes message admission. + * Pending starts share one abort signal; published children share idempotent + * cleanup, and quiescence waits for both while synthesizing any missing end events. * @module @deepseek-ai/dsh-workflow-workerthread/host */ @@ -64,29 +29,10 @@ interface ChildRecord { } /** - * 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 a JavaScript data-URL bootstrap. That bootstrap runs INSIDE the - * user worker, registers tsx's ESM AND CommonJS transforms there, and only - * then imports the TypeScript sibling. The whole mixed-module source graph - * therefore receives TypeScript transformation and the tsconfig paths map in - * the worker's own module-loader realm. A worker inherits no - * transform pipeline from vitest (vite transforms in-process), and a parent - * `--import tsx` registration is not a contract that user workers share on - * every supported Node line. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed (`execArgv` - * pinned empty in both shapes — 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). + * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx + * transforms inside the worker. Both shapes clear `execArgv` and the ambient + * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path + * resolution. * @param init - the run payload, passed as `workerData`. * @returns the entry path or URL and the Worker options to spawn it with. */ @@ -95,15 +41,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W if (!import.meta.url.endsWith('.ts')) { return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } } - // Resolve tsx lazily: only the unbuilt shape executes this arm, so a built - // consumer never needs the dev-only loader installed. A JavaScript entry is - // essential — it can install tsx's ESM and CommonJS hooks from INSIDE the - // user worker before any TypeScript enters Node's native strip-only parser. - // Both hooks are load-bearing because the source graph crosses both module - // shapes on supported Node lines. TSX_TSCONFIG_PATH is - // the one variable forwarded through the scrub: a parent running outside - // the repo cwd (the ACP snapshot harness is the real case) pins the paths - // map through it. Loader plumbing, not a secret. + // Resolve tsx only for unbuilt consumers and install it before importing TS. const workerEntry = new URL('./worker.ts', import.meta.url) const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api') const tsxCjsApiEntry = import.meta.resolve('tsx/cjs/api') diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index a1f5b47ccc..44ab91f465 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -1,41 +1,8 @@ /** - * 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`). - * + * Worker-thread workflow engine. Each run executes its model-written script in + * an escapable vm context on a fresh worker and bridges `agent()` calls to host + * subagents. The thread prevents synchronous script work from blocking the host + * and permits forced termination, but it is containment rather than a security boundary. * @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..c223be28fa 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,13 +1,8 @@ /** - * 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. Meta arrives as schema-checked + * JSON data, never evaluated script text; evaluating it on the host could run getters outside the + * worker timeout that exists to isolate model-written code. * @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 70edc9a713..b981447ed4 100644 --- a/packages/workflow/workflow-workerthread/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -1,18 +1,9 @@ /** - * 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. Payloads are plain JSON by construction for structured clone. Both + * directions are closed engine protocols whose receivers use `assertNever`; generic typed senders + * make tag/payload mismatches compile-time errors rather than silently skipped messages. * @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..b16e2f6860 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,32 +1,10 @@ /** - * 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. - * + * Materializes values leaving the script vm into plain JSON before they cross the worker + * boundary, and renders thrown script values without rejecting the run. The walk rejects + * lossy JSON shapes but trusts model-written workflow scripts: getters and proxy traps may + * run, and the vm is not a security boundary. The worker provides host-loop isolation and + * forced termination, not hostile-value containment. See + * docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale. * @module @deepseek-ai/dsh-workflow-workerthread/realm */ @@ -74,17 +52,16 @@ 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. + * Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is + * returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail + * with the offending path. Property accessors run normally, and a throwing read is wrapped + * with its rendered failure. + * * @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). + * @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic + * prototypes, or property reads that throw. */ export function materializeFromRealm(value: unknown, root = 'value'): unknown { if (value === undefined) return undefined diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 94282cb173..a99ce495a4 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,39 +1,14 @@ /** - * 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, - * provider-start 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 worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it + * never touches Cordis. Script values leaving the realm are materialized as plain JSON before + * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is + * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model. * + * Fatal workflow errors—bad hook arguments, unsupported schemas/options, caps, start failures, and + * cancellation—propagate through combinators. Only child failures and ordinary stage errors become + * per-item nulls. Every returned promise has a rejection consumer so dropped script promises cannot + * kill the worker. A cancelled script that never settles emits nothing; the host force-settles the + * run within grace and terminates the thread. * @module @deepseek-ai/dsh-workflow-workerthread/runtime */ diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index 671a9429ae..7fadb45c98 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -1,19 +1,13 @@ /** - * 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. Keeping it + * separate from `worker.ts` lets unit tests drive the session over a MessageChannel, because main + * process coverage cannot observe code inside a real Worker. * + * The session announces ready and waits for `go`, so cancellation racing startup can prevent even + * the script's synchronous prefix. A cancel in place of `go` releases the gate into a cancelled + * drive without executing the body. * @module @deepseek-ai/dsh-workflow-workerthread/session */ @@ -137,12 +131,11 @@ 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). It never rejects: + * constructor failure becomes an error result. Host pre-parse makes syntax failure here a likely + * Node-version skew, but the session still reports it instead of dying silently. * @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 2f29dd8137..6adb422b2a 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -1,11 +1,7 @@ /** - * 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. Host/worker messages are defined in + * `./protocol.ts`; transported child requests and results are plain JSON for structured clone. * @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..f204ff0493 100644 --- a/packages/workflow/workflow-workerthread/src/worker.ts +++ b/packages/workflow/workflow-workerthread/src/worker.ts @@ -1,11 +1,7 @@ /** - * 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. - * + * Single-statement worker entry that boots `runWorkerSession` on real `parentPort`. Logic remains in + * the session module for in-process MessageChannel coverage; importing this entry on the main thread + * exercises `requireParentPort`'s failure path. * @module @deepseek-ai/dsh-workflow-workerthread/worker */ diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index 779dd50600..e9d9a9b4e1 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -12,17 +12,12 @@ const builtWorker = join(packageRoot, 'lib', 'worker.cjs') const run = promisify(execFile) /** - * The BUILT-output guard for the worker entry: every other suite runs - * unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves - * its sibling `lib/worker.cjs` and that the bundle boots a worker under plain - * node (no tsx loader). Keyless — a zero-agent script needs no provider — - * and self-skips until `pnpm run build` has produced the bundles. + * Keyless built-artifact guard: plain Node loads `lib/index.js` and its sibling + * `lib/worker.cjs` without tsx. Skips until the build produces both bundles. */ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.cjs)', () => { it('the built engine spawns its built worker under plain node and completes a run', async () => { - // ESM resolves bare specifiers from the IMPORTING FILE's location, so the - // driver must live inside the package for its node_modules to apply — a - // temp-named file at the package root, removed on the way out. + // Keep the driver in-package so bare imports resolve its node_modules. const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`) try { await writeFile(driver, ` @@ -36,7 +31,7 @@ await ctx.plugin(WorkerWorkflowEngine, {}) const run = ctx.workflows.start({ script: 'return 6 * 7', meta: { name: 'built-smoke', description: 'built worker smoke' }, - // A zero-agent script never touches the provider, so a bare id suffices. + // A zero-agent script never touches the provider. parent: { id: 'built-smoke-parent', options: {} }, }) const result = await run.result @@ -47,7 +42,6 @@ if (result.stopReason !== 'completed' || result.value !== 42) { } console.log('built-worker-smoke-ok') `, 'utf8') - // Plain node — no tsx loader anywhere; the bundle must stand on its own. const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 }) expect(stdout).toContain('built-worker-smoke-ok') } finally { diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index 5fc85ce647..07a9bac8d0 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -437,10 +437,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { void runWorkerSession(host.port, init("return await agent('p')")) await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId - // Cancel FIRST, then the (stale) started reply: the worker processes them - // in order, so the agent() continuation resumes already-cancelled — the - // window the real host cannot produce (it refuses starts once cancelled) - // but a teardown race can. + // Simulate a teardown race by delivering cancellation before a stale start reply. host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' }) host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) const result = await host.result() @@ -448,7 +445,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) }) - // The child never became an agent-start: it was wound down pre-lifecycle. + // The unpublished child is disposed without a lifecycle announcement. expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([]) host.close() }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 4ad00ee02f..e85bec9375 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -16,27 +16,13 @@ function fakeParent(): Agent { return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent } -// Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on -// every start): on a contended CI runner it regularly blows past vitest's 5s -// default test timeout, observed repeatedly on the coverage lane. +// Allow cold worker startup on contended CI runners. 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. + * Wait up to 10 seconds for CPU-bound worker startup or cross-thread delivery on contended CI. + * Host reactions after an observed event use explicit tight overrides, so this generous startup + * allowance cannot hide multi-second reap regressions. */ function waitFor(assertion: () => void, timeout = 10_000): Promise { return vi.waitFor(assertion, { timeout, interval: 50 }) diff --git a/packages/workflow/workflow-workerthread/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts index c163fe4369..8ebd93d89f 100644 --- a/packages/workflow/workflow-workerthread/tsdown.config.ts +++ b/packages/workflow/workflow-workerthread/tsdown.config.ts @@ -1,14 +1,9 @@ import { defineConfig } from 'tsdown' /** - * The engine ships two runtime entries: the engine service (index) and the - * worker-thread entry (worker) the engine spawns via `new Worker`. The - * entries are JS emitted by tsc under lib/types and are bundled as two - * single-entry passes so shared modules (realm, runtime, session) are inlined - * into each instead of split into a hash-named chunk (the worker entry must - * be a self-contained file the Worker constructor can load by path). The - * worker bundle is CommonJS because pkg's VFS Worker hook compiles - * filesystem-string entries as CommonJS. + * Build the engine and worker separately so each inlines shared modules; a + * multi-entry build creates an unlisted chunk. The path-loaded worker is + * CommonJS because pkg's VFS Worker hook compiles it in that format. */ export default defineConfig([ { diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 0fa0289b41..f1ac439b68 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -1,21 +1,6 @@ /** - * 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: 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. Same-process payloads are borrowed - * immutable values. Every listener is independently contained, so a throw or - * rejected promise can neither strand a run nor starve peers. - * + * Workflow capability seam. Implementations execute orchestration scripts; + * observe-only lifecycle events never expose run control. * @module @deepseek-ai/dsh-workflow */ @@ -117,27 +102,10 @@ 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` — the provider's asynchronous start rejected before - * cancellation took precedence. - * - `AGENT_RESULT` — a ready run had its `result` REJECT: an infrastructure - * fault at the subagent seam. 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). + * Machine-routable fatal workflow failures: parse/meta/argument/schema errors, + * resource caps, subagent infrastructure failures, unserializable boundary + * values, and cancellation. An ordinary child failure resolves its item to + * `null` and is not one of these fatal codes. */ export type WorkflowErrorCode = | 'SCRIPT_PARSE' @@ -182,31 +150,11 @@ 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} (borrowed - * immutable data, 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. + * Workflow execution seam. Invalid requests throw before publication; a live + * run is holder-owned, its result never rejects, cancellation and disposal are + * bounded, and disposal waits for child cleanup within that bound. Lifecycle + * listener failures are contained, and `workflow/end` fires exactly once as the + * result settles. */ export abstract class WorkflowService extends Service { constructor(ctx: Context) { @@ -222,14 +170,7 @@ export abstract class WorkflowService extends Service { abstract start(request: WorkflowStartRequest): WorkflowRun /** - * Emit one `workflow/*` lifecycle event with per-listener containment. Each - * subscriber receives the same borrowed immutable payload; a throw or - * asynchronously rejected 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 a lifecycle event while containing and logging each listener failure. * @param name - the `workflow/*` event to dispatch. * @param args - the event's payload, matching its declared signature. */ @@ -248,10 +189,7 @@ 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 any thrown value without violating listener containment. * @param error - any thrown value. * @returns `String(error)`, or a fixed label when even coercion throws. */ @@ -259,8 +197,7 @@ 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 may throw. return '[unrenderable thrown value]' } } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 981a2da172..5a00cfbb6d 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -106,16 +106,10 @@ 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). + * Holder-owned live workflow. `result` never rejects and settles within the + * engine's cancellation grace; failures resolve through `stopReason`. Consumers + * may cancel and must call idempotent `dispose()` on every path to await bounded + * script settlement and child quiescence. */ export interface WorkflowRun { readonly id: WorkflowRunId diff --git a/pytest.ini b/pytest.ini index 696fcc2bf3..8e5f4765b2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,5 @@ -# Root pytest config: pin collection to the Python SDK's real test tree. -# Without this, a bare `pytest` from the repo root recursively collects the -# whole worktree — including gitignored residue such as scratch checkouts or -# venvs — and same-basename test modules collide with an "import file -# mismatch" collection error. +# Restrict root collection to SDK tests; recursive collection can include +# ignored worktrees or venvs and collide on same-named modules. [pytest] testpaths = python/sdk/tests norecursedirs = node_modules .git dist-exe diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 64e10faf58..58b7c68a78 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 35ed645ce0e4d5e4c88c8aae2fe33d94aea79b3e -README.zh.md: 214c5cd1900e52f9fe479247f9940a97bb22766b +README.md: a04a0f99c95337b4d9e073e97075654190929520 +README.zh.md: 04e58c5a399565994df721d018ee0fc8a1d86578 diff --git a/python/README.md b/python/README.md index 35ed645ce0..a04a0f99c9 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries but retains only the four release wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index 214c5cd190..04e58c5a39 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制,但只保留 4 个发布用 wheel 包。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index f369d13463..7b6b50e50d 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4beac57526761bb150e90b60f0030ed311c4034d -README.zh.md: c0cc0eef6a9b569105408d2f2e6321795493036a +README.md: a28ae59971e1997f8129c04518307eabece0b6de +README.zh.md: 10dbb6b14f488072382eb01da7621c9bbb0dbcec diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 4beac57526..a28ae59971 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,7 +13,7 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. -Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. @@ -26,4 +26,4 @@ Each wheel contains exactly one executable. The fixed tags are `py3-none-manylin ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` (the JSON-RPC serving entry, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash, each parameterized by the `DSH_*` env vars the SDK sets); when the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, and local bash. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence and bash use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index c0cc0eef6a..10dbb6b14f 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,7 +13,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 -载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 +exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 @@ -26,4 +26,4 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化与本地 bash。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化与 bash 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a843b2f04a..cff5eda404 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -1,6 +1,6 @@ { "name": "dsh-jsonrpc-agent-pkg", - "description": "Deploy root of the single-exe pipeline and the single source of truth unifying 'which plugins the exe bundles' and 'what the Python runtime distributes': the dependency list below IS the exe closure. Pure manifest — no code; a deploy materializes only this package.json plus node_modules.", + "description": "Dependency-only deploy root defining the executable and Python runtime closure; pnpm deploy materializes this manifest and node_modules.", "version": "0.0.1", "private": true, "type": "module", diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index d0f1d97bf5..e04b9e6728 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -10,9 +10,8 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "BSD-3-Clause" } -# Distributions carry the platform executables (build-injected, VCS-ignored — -# hence `artifacts`) and the checked-in runtime/cordis.yml; the dev-only node -# closure under runtime/node/ is explicitly excluded from wheel and sdist. +# Include the injected executable and default config; exclude the dev-only node +# closure from wheels and sdists. [tool.hatch.build] artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] exclude = ["src/deepseek_harness_runtime/runtime/node"] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 76ae18b20e..61e19be267 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -1,30 +1,17 @@ -# Default runtime configuration for the bundled dsh-jsonrpc-agent. The runtime -# binary has NO built-in fallback — it always requires an explicit config via -# `$DSH_CORDIS_CONFIG` (wins) or an argv positional path. The Python client SDK -# injects THIS file's path via `$DSH_CORDIS_CONFIG` when the caller supplies -# no config and the launch resolves to the bundled runtime; that explicit -# injection is what restores the zero-config experience. The runtime bin only -# boots this config; the serving surface (the stdio JSON-RPC server) comes -# from the @deepseek-ai/dsh-jsonrpc entry below. -# -# $DSH_SESSION_ROOT and $DSH_CWD are set by the SDK per launch; the `!!js` -# fallbacks keep this file usable when the runtime is driven manually. +# Bundled default config. The runtime still requires an explicit +# $DSH_CORDIS_CONFIG or argv path; the SDK injects this path for bundled +# zero-config launches. SDK-set session-root and cwd variables have manual-run fallbacks. -# The serving surface: HarnessSdkServer + line-delimited JSON-RPC transport on -# stdio. Without this entry the runtime boots an agent nobody can talk to. +# Stdio JSON-RPC serving surface; without it the agent has no SDK client. - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' -# The agent spine bundle: session store, system prompt, tool registry, agent -# registry, and the agent loop. No pre-created agents — the SDK server creates -# one per session/prompt sessionId. +# Agent spine; the SDK server creates agents per sessionId. - id: agent-core name: '@deepseek-ai/dsh-agent-core' -# The DeepSeek adapter, preloaded for the stock models. The adapter fails loud -# at load without an API key, so keyless boots must still export a dummy -# DEEPSEEK_API_KEY (initialize/shutdown never call the model). baseURL falls -# back to the public endpoint when unset. +# Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown +# may use a dummy key because they do not call the model. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: @@ -34,16 +21,13 @@ - deepseek-v4-flash - deepseek-v4-pro -# JSONL session persistence. $DSH_SESSION_ROOT (set by the SDK whenever -# `session_root` is configured) wins; otherwise ./.sessions relative to the -# runtime process cwd. +# JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' -# Local bash executor behind the spine's `bash` tool. $DSH_CWD (always set by -# the SDK) wins; otherwise the runtime process cwd. +# Local bash executor; $DSH_CWD wins over the process cwd. - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index a294dc1194..c6f8e4de1d 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 60540376c5fd85b0852e204bc8bad3f01c849de5 -README.zh.md: 241c06057889f1aa4add6fc54024fba92bd19429 +README.md: 441b335b9e850c221fbd6a657c7de539ceca070d +README.zh.md: 65134c0e856b933c793510c2ed528e97f940f47d diff --git a/python/sdk/README.md b/python/sdk/README.md index 60540376c5..441b335b9e 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -35,6 +35,6 @@ with DeepSeekHarness( `assistant/message` event in the turn. Use `TurnResult.events` for the complete event stream, including intermediate assistant messages and tool activity. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. `cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 241c060578..65134c0e85 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -29,6 +29,6 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 -同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 `cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署的角色设定与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index 859a2d6282..b0f1acf5f2 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -24,8 +24,7 @@ testpaths = ["tests"] [tool.hatch.build.targets.wheel] packages = ["src/deepseek_harness"] -# Editable: the runtime package's executables are injected into its source -# tree AFTER install (by scripts/build-exe-for-python-sdk.ts or a manual copy); an -# editable install sees them immediately instead of freezing a wheel snapshot. +# Editable installs see runtime executables injected after installation instead +# of freezing a wheel snapshot. [tool.uv.sources] deepseek-harness-runtime-bin = { path = "../sdk-runtime", editable = true } diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index be457c538a..f5126c21c6 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -416,16 +416,10 @@ class HarnessClient: return resolve_bundled_launch_args() def _inject_bundled_default_config(self, env: dict[str, str]) -> None: - """Restore the zero-config experience over the config-mandatory bundled runtime. + """Inject the default config for a bundled launch with no non-empty config. - The bundled runtime (single-file exe or the dev-only node closure) - always demands an explicit config. When the launch resolves to the - bundled runtime (no ``runtime_bin`` / ``bridge_bin`` / - ``launch_args_override``) and the merged subprocess environment has no - non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty - value as absent, so this does too — inject the runtime package's - checked-in default cordis.yml. With an explicit runtime or config - channel the client stays out of the way. + Both bundled carriers require an explicit config. Explicit runtime, + launch-argument, and config channels remain untouched. """ uses_bundled_runtime = ( self.config.launch_args_override is None @@ -434,9 +428,7 @@ class HarnessClient: ) if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): return - # Cannot fail: _default_launch_args() already imported the runtime - # package on this (bundled) path, raising the actionable install - # error when it is absent. + # _default_launch_args already imported the package or raised its install error. from deepseek_harness_runtime import bundled_default_config_path env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index d259d7dab7..8173c40f7f 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -1,12 +1,7 @@ -"""Manual keyless smoke: drive the repo-source jsonrpc-agent bin (node + tsx). +"""Drive the repo-source JSON-RPC bin through the SDK and a keyless mock SSE server. -Runs the SDK against `packages/ui/jsonrpc-agent/src/bin.ts` executed from the -repo checkout (requires `pnpm install`; no build, no API key — the model -endpoint is a local mock SSE server). The bin only boots the supplied -cordis.yml — the stdio JSON-RPC server itself comes from the config's -`@deepseek-ai/dsh-jsonrpc` entry — so the runtime package's default cordis.yml -is passed explicitly. Not collected by pytest; run it directly: -`python tests/manual_sdk_agent_smoke.py`. +Requires ``pnpm install`` but no build. This manual test is not collected by +pytest; run ``python tests/manual_sdk_agent_smoke.py``. """ from __future__ import annotations diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index a55c6c2c6a..e480147dfe 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -1,12 +1,7 @@ -"""Smoke tests against the bundled dsh-jsonrpc-agent artifacts. +"""Keyless boot tests for the production exe and development node carrier. -These boot the runtime the way an installed SDK does, once per bundled -carrier: the platform single-file exe (production) and the dev-only node -closure under ``runtime/node`` driven by system ``node``. Each carrier skips -independently when its artifact is absent on this machine — build or fetch it -per the FileNotFoundError guidance quoted in the skip reason. Keyless: the -dummy DEEPSEEK_API_KEY only satisfies the adapter's load-time check; -initialize/shutdown never call a model. +Each carrier skips independently when absent. The dummy API key only satisfies +adapter loading; initialize and shutdown do not call a model. """ from __future__ import annotations @@ -21,8 +16,7 @@ from deepseek_harness_runtime import resolve_bundled_launch_args _MODES = ("exe", "node") -# The serving surface is itself a plugin: without the dsh-jsonrpc entry the -# runtime boots an agent nobody can talk to and exits 0 on stdin EOF. +# The config must include the JSON-RPC serving plugin. _CORDIS_YML = """\ - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' @@ -57,9 +51,7 @@ def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: "DSH_CORDIS_CONFIG": "./cordis.yml", "DSH_SESSION_ROOT": str(tmp_path / "sessions"), "DSH_CWD": str(tmp_path), - # initialize() lazily mounts the llm-deepseek adapter for the - # requested model; a dummy key keeps the keyless boot green - # (initialize/shutdown never call the model). + # The lazily mounted adapter requires a key even without a model call. "DEEPSEEK_API_KEY": "sk-dummy-for-boot", "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", }, @@ -119,7 +111,4 @@ def test_zero_config_run_injects_bundled_default_cordis_config( request_timeout_seconds=120, ) with harness: - # __enter__ boots the runtime, which exits with a usage error unless - # HarnessClient.start() injected the bundled default config over the - # unset/empty DSH_CORDIS_CONFIG; __exit__ shuts it down. pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 327e5f5c39..f7d0cfa8a3 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -682,11 +682,9 @@ with open(os.environ["SEEN"], "w") as seen: def _install_fake_bundled_runtime( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> Path: - """Fake the deepseek-harness-runtime-bin package on sys.path. + """Install a fake runtime package that records config and serves lifecycle calls. - A stub exe that dumps DSH_CORDIS_CONFIG to $ENV_DUMP before serving - initialize/shutdown, plus a module exposing the resolution surface the - client consumes. Returns the fake bundled default config path. + Returns the fake bundled default config path. """ runtime = tmp_path / "dsh-jsonrpc-agent" runtime.write_text( diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 4549d4dd29..d05c4fded7 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -1,9 +1,4 @@ -"""Keyless tests for the deepseek_harness_runtime resolution API. - -These never launch a runtime, so they run everywhere regardless of which -bundled artifacts are present; the launch-and-boot coverage lives in -``test_bundled_runtime.py``. -""" +"""Keyless runtime-resolution tests; launch coverage lives in test_bundled_runtime.py.""" from __future__ import annotations diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 6f29438d42..8501bde1fe 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -1,49 +1,9 @@ /** - * Build the single-file SDK runtime executables - * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - * - * Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's - * standard mode unusable for this architecture (its ESM→CJS transform breaks - * every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain - * ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree. - * - * Pipeline — every step fails loud with the command it ran: - * - * 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build). - * 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the - * closure-manifest package (python/sdk-runtime/package.json — the single - * source of truth for the exe's plugin set) into the staging dir - * (cleared first; pnpm refuses a non-empty deploy target). Flags, all - * verified against pnpm 11.7: `--legacy` because the workspace does not - * set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain - * file tree with zero symlinks (the safe shape for pkg's VFS, and it - * physically guarantees a single cordis copy); `auto-install-peers=false` - * so transitive `^0.0.x` peers on unpublished packages never hit the - * registry; `link-workspace-packages=true` so the closure resolves to - * workspace/vendor sources. - * 3. Inject the pkg config into the staged package.json: `bin` = the ESM - * `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode - * hands it to Node's default ESM loader — no CJS shim), plus whole-tree - * asset globs. The cordis Loader resolves plugins - * through runtime dynamic `import()` of bare package names, so pkg's - * static analysis discovers none of them — the entire staged tree must be - * globbed in explicitly. - * 4. `pnpm dlx @yao-pkg/pkg@ --sea --targets --output - * /dsh-jsonrpc-agent-pkg--` — once per target (SEA mode - * packs a single target per invocation), so each product gets its - * canonical name directly. - * 5. Sync into the Python runtime package - * (python/sdk-runtime/src/deepseek_harness_runtime/runtime/, - * created if missing): each product under its canonical filename (exe - * mode), plus the whole staged closure into runtime/node/ (node mode — - * `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` - * runs it directly; the injected pkg - * fields are harmless to node). dist-exe/ keeps the originals for CI - * artifact upload. - * - * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/ - * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` - * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing + * Build the SDK runtime executables and Python node carrier. The fixed + * `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by + * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. + * The staged closure is symlink-free, and whole-tree assets cover Cordis's + * runtime imports that pkg cannot discover statically. */ import { spawn } from 'node:child_process' @@ -54,43 +14,27 @@ import { parseArgs } from 'node:util' const root = resolve(import.meta.dirname, '..') -/** - * The deploy root: the closure-manifest package (python/sdk-runtime) whose - * dependencies define the exe's contents; the runnable entry inside the - * closure is {@link ENTRY_BIN}. - */ +/** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' -/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */ +/** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js' -/** Basename of every product; the canonical name appends `--`. */ const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' -/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */ +/** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' -/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */ +/** Pinned for reproducible builds. */ const PKG_SPEC = '@yao-pkg/pkg@6.21.0' -/** Staging dir for the deployed closure — cleared on every run (gitignored). */ -// (No external staging dir: the deploy target IS the Python runtime's -// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.) -/** Product output dir (gitignored). */ const OUT_DIR = 'dist-exe' -/** - * Python runtime package dir the products are synced into. A parallel change - * owns the directory and its .gitignore; this script's only contract is the - * destination path, so a missing dir is created, never an error. - */ +/** Python package destination; created when absent. */ const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' -/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ +/** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' -/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */ +/** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] /** - * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names - * at runtime, invisible to pkg's static analysis, so every runtime file in the - * closure is listed; SEA mode ships them as plain source in the VFS. Every - * package.json must ride along — bare-name resolution dies without them (the - * json glob would already match, but the manifests are resolution-critical, so - * they get their own explicit entry). + * Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's + * static analysis cannot see. Package manifests are explicit because bare-name + * resolution depends on them. */ const ASSET_GLOBS = [ 'package.json', @@ -108,24 +52,20 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] -/** True when `value` is a supported pkg platform tag. */ function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } -/** True when `value` is a supported pkg CPU tag. */ function isArch(value: string): value is Arch { return (ARCHES as readonly string[]).includes(value) } /** - * One pkg target triple, e.g. `node24-linux-x64`, as an immutable value. - * Construction goes through {@link Target.parse} (a `--targets` entry) or - * {@link Target.host} (the default), which own all validation. + * A parsed pkg target triple, constructed from `--targets` or the host. */ class Target { private constructor( - /** pkg Node range (`node`); pins the official base binary pkg pulls. */ + /** pkg Node range (`node`). */ readonly nodeRange: string, /** * pkg platform tag. Windows is a documented non-goal @@ -142,7 +82,7 @@ class Target { } /** - * Parse and validate one target spec; throws on any malformed component. + * Parse one target spec, rejecting malformed triples and unsupported platform or architecture. * @param spec - the raw triple, e.g. `node24-linux-x64`. * @returns the parsed target. */ @@ -156,7 +96,7 @@ class Target { throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`) } if (!isPlatform(platform)) { - throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`) + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`) } if (!isArch(arch)) { throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) @@ -165,7 +105,7 @@ class Target { } /** - * The default target when --targets is omitted: the host platform on node24. + * Resolve the host-platform default on Node 24. * @returns the host target; throws on an unsupported host platform or arch. */ static host(): Target { @@ -182,9 +122,7 @@ class Target { } /** - * Parsed CLI configuration. {@link BuildCli.parse} is the only constructor - * path — it owns flag parsing, target validation, and the --help / bad-flag - * process exits, so an instance always holds a valid plan. + * Validated CLI configuration; construction owns help and parse-error exits. */ class BuildCli { private constructor( @@ -197,9 +135,8 @@ class BuildCli { ) {} /** - * Parse argv into a validated configuration. Exits the process for --help - * (code 0, usage) and for unknown/malformed flags (code 1, usage on - * stderr); throws on invalid or colliding targets. + * Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding + * targets throw. * @param argv - the raw arguments (`process.argv.slice(2)`). * @returns the parsed, validated configuration. */ @@ -231,7 +168,6 @@ class BuildCli { return new BuildCli(targets, values['skip-build'], values['dry-run']) } - /** The flag grammar in one place; parseArgs throws on any unknown flag. */ private static parseRaw(argv: string[]) { return parseArgs({ args: argv, @@ -244,7 +180,6 @@ class BuildCli { }).values } - /** The --help text; also printed under flag-parse errors. */ private static usage(): string { return [ 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', @@ -255,21 +190,18 @@ class BuildCli { ' --dry-run print every command and config patch without executing.', ' --help print this help.', '', - 'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode', - `(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`, - `hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`, + `Build route: ${PKG_SPEC} --sea; see docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`, + `Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`, ].join('\n') } } -/** The pnpm executable name for the host OS. */ function pnpmBin(): string { return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' } /** - * Render a command line for logs and error messages, quoting arguments that - * contain spaces. + * Render a command for logs and errors, quoting arguments with spaces. * @param command - the executable. * @param args - its arguments. * @returns the printable command line. @@ -279,30 +211,25 @@ function formatCommand(command: string, args: string[]): string { } /** - * The four-step build pipeline over one parsed CLI. Steps are sequential - * async methods; every subprocess inherits stdio and fails loud with the - * exact command it ran. In --dry-run the command/filesystem layer prints - * what it would do instead of executing. + * Sequential build pipeline. Subprocesses inherit stdio and errors include + * the command; dry runs print commands and filesystem changes. */ class SingleExeBuild { /** - * Absolute staging dir — the Python runtime's node-mode carrier: step 2 - * deploys the closure DIRECTLY here (cleared first; it is a pure build - * product, the checked-in default `cordis.yml` lives one level up), step 4 - * reads it as the pkg input, and node mode runs it in place. + * The cleared deploy target, pkg input, and Python node-mode carrier. The + * checked-in default `cordis.yml` remains in its parent directory. */ readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) - /** Absolute product output dir. */ private readonly outDir = resolve(root, OUT_DIR) constructor(private readonly cli: BuildCli) {} - /** Gate the manifest before spending time compiling or packaging it. */ + /** Verify the closure before compiling or packaging. */ async verifyClosure(): Promise { await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) } - /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ + /** Build all package artifacts unless `--skip-build` was passed. */ async build(): Promise { if (this.cli.skipBuild) { console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') @@ -311,7 +238,7 @@ class SingleExeBuild { await this.run('build', pnpmBin(), ['run', 'build']) } - /** Step 2: clear the staging dir and deploy the bridge closure into it. */ + /** Clear and deploy the runtime closure into the node carrier. */ async deployStaging(): Promise { if (this.staging === root || root.startsWith(this.staging + sep)) { throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`) @@ -336,7 +263,7 @@ class SingleExeBuild { } } - /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } const manifestPath = join(this.staging, 'package.json') @@ -356,8 +283,7 @@ class SingleExeBuild { } /** - * Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode - * packs a single target per invocation) and return the product path. + * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. */ @@ -381,7 +307,7 @@ class SingleExeBuild { } /** - * Print each product path (and size, when it exists on disk). + * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ printProducts(products: string[]): void { @@ -397,10 +323,8 @@ class SingleExeBuild { } /** - * Step 5: copy every product into the Python runtime package under its - * canonical filename (exe mode). The node-mode carrier needs no sync — step - * 2 deployed the closure into it directly. dist-exe/ keeps the originals - * for CI artifact upload; the destination dir is created if missing. + * Copy each executable into the Python runtime package. The deployed node + * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ async syncToPythonRuntime(products: string[]): Promise { @@ -420,9 +344,8 @@ class SingleExeBuild { } /** - * Run one pipeline step as a subprocess with inherited stdio; reject — - * carrying the printable command — on spawn failure and non-zero exit - * alike. In --dry-run, print the command instead of executing. + * Run one subprocess with inherited stdio. Spawn and non-zero-exit errors + * include the command; dry runs only print it. * @param label - the step name used in logs and error messages. * @param command - the executable. * @param args - its arguments. @@ -451,7 +374,6 @@ class SingleExeBuild { } } -/** Entry point: parse the CLI, then await each pipeline step in order. */ async function main(): Promise { const cli = BuildCli.parse(process.argv.slice(2)) const pipeline = new SingleExeBuild(cli) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index a1cbfccd09..4dac840da2 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -178,13 +178,8 @@ 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. + * Enforce `packages//`: groups are open-named containers without a + * package.json, and packages may be neither flat nor more deeply nested. */ function checkHierarchyShape(): string[] { const errors: string[] = [] diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index d8c975190c..76d5fe9215 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,12 +1,7 @@ /** - * Boot the Code Mode demo under the UI named on the command line: - * `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the - * point — the UI is just the surface it happens to wear: each UI boots its - * base example through that example's `code-mode.cordis.yml` overlay - * (include ./cordis.yml, flip `tools.mode` to `code`, insert the - * worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env - * works). Anything else on the command line is a misconfiguration and - * fails loud with usage. + * Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Each overlay + * includes its base example, selects Code Mode, and adds the worker runtime. + * Both require a DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 235b8f21fa..a03f7ca25a 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": 1100, "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": 200, + "packages/AGENTS.md": 290, "packages/README.md": 710 } diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index e57f3710ee..dfcbbf844c 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' @@ -32,28 +12,9 @@ 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. + * TypeScript-fence ownership. `check` compiles; `ignore` is an unchecked sketch + * counted in the opt-out ratio; the catalog and type-equivalence variants are + * excluded from that ratio because their owning gates verify them. */ type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog' @@ -66,8 +27,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 +47,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 +105,8 @@ 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. +// Only compile-eligible fences belong in the opt-out ratio; every other skipped +// kind has an independent verifier named in BlockKind's contract above. 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..b5d2fa53d9 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -1,68 +1,10 @@ /** - * 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 every enumerable schema path must + * exist on the declared config type. External and dynamic shapes stay unknown; + * declared runtime-only fields need not appear in the schema. `--check` verifies + * the committed artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -353,7 +295,7 @@ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: Type entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache) } catch { // A workspace package without a readable entry is reported by its own - // classification pass; for a lookup it is merely out of reach. + // classification pass; for a lookup it is out of reach. return 'unknown' } return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown' @@ -372,9 +314,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 + // Guard only named declarations, where recursive types can loop. Structural + // children can share a source position with their parent, so guarding them // would mistake ordinary descent for a cycle. if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { const key = `${ctx.abs}:${node.pos}:${steps.length}` @@ -775,10 +716,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. + // Fold composed schemas' key paths in, then check each path against the type. + // Only a definite miss fails; shapes the walk cannot enumerate stay unknown. 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..cc87c379e9 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -1,27 +1,8 @@ /** - * 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. It emits first-sentence docs, raw + * signatures, transitive public type shapes, and inherited context entries, + * without source pointers; output is deterministic and `--check` verifies it. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -48,10 +29,8 @@ function quote(value: string): string { } /** - * 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 exported interface and type shapes; omit names declared in multiple + * packages rather than risk serving the wrong package's shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) @@ -78,11 +57,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 and sort the word-bounded transitive type closure 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 68139e9ff8..a32107b53b 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' @@ -66,19 +19,11 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md' 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. + * One primary core-data-structures page per signature type, shared by the + * Cordis and config catalogs; union names intentionally do not reuse the + * type-equivalence manifest's map-symbol entries. */ +// TODO(catalog-type-links): verify or generate link-map coverage. export const LINK_MAP: Record = { Agent: 'core.md', ContentBlock: 'core.md', @@ -216,10 +161,8 @@ 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. The `this` receiver is not + // payload, and a waterfall's trailing `next` is covered by its mode. const { params } = parseTags(raw) checkParams(where, 'event', member.parameters, params, sf, p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) @@ -270,10 +213,8 @@ 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 instance methods callable through `ctx.` are surface; + // private, protected, and static methods are not. 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 a970499268..468a46e904 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1,21 +1,8 @@ /** - * 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 + * Generate the relationship layer above the module, Cordis, and tool catalogs. + * Enumerable facts come from source; hybrid graphs add manifests for policy the + * source cannot infer, while curated graphs explain flow and ownership. + * `--check` verifies the generated set. */ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -576,13 +563,8 @@ 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 are conventional names. Keep this list in sync + // with renames or the relationship matrix can silently lose an edge. return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx' } @@ -629,13 +611,8 @@ 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. + // Every declared event needs a dispatcher: zero means dead vocabulary or an + // unrecognized dispatch spelling. Listener-free extension points remain valid. const undispatched = [...events] .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0) .map(event => event.name) @@ -762,7 +739,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 97c462a5a6..6ae5dd3044 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -1,21 +1,7 @@ /** - * 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) + * Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical + * runtime edges. The deterministic output groups packages by directory and + * renders both Mermaid and a dependency table; `--check` verifies freshness. */ import { resolve } from 'node:path' @@ -112,10 +98,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. + // A missing artifact is the expected read failure. Any read failure has the + // same remedy here—regenerate—so it is reported as stale below. committed = null } if (committed === content) { diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index ca56611424..5c93d8875e 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -1,45 +1,9 @@ /** - * 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. This is the durable-record vocabulary, + * not the live Cordis bus. Event declarations must be unique, explicitly typed, + * documented, inheritance-free, and free of Cordis-only `@mode` tags; every + * surface-union member must resolve to one. `--check` verifies the artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -57,14 +21,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', @@ -99,12 +56,9 @@ export interface AnnotatedLogEventEntry extends LogEventEntry { const printer = ts.createPrinter({ removeComments: true }) /** - * One-line payload text for a member's type annotation. Printed through the - * TypeScript printer (not sliced from source text): the printer emits `;` - * member separators regardless of how the source separated them, so a - * multi-line newline-separated type literal still collapses to a VALID - * single-line fragment. The trailing `;` the printer puts before every `}` is - * dropped to match the repo's inline-literal style. + * Render a member type on one line through the TypeScript printer, which adds + * semicolon separators. Drop its trailing semicolon before `}` to match the + * repository's inline-literal style. */ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string { return printer.printNode(ts.EmitHint.Unspecified, type, sf) @@ -155,16 +109,8 @@ 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 every `SessionEventMap` merge, rejecting inherited, non-literal, + * untyped, undocumented, duplicate, or incorrectly owned members in one report. */ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { const entries: LogEventEntry[] = [] @@ -179,11 +125,9 @@ 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 has one home: the single exported declaration in + // the owning package. Same-named interfaces elsewhere are different + // types and must not enter the on-disk catalog. 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 9e7dfe44bd..ab6b01c10d 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,36 +1,9 @@ /** - * 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. Rationale and ownership live in + * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -64,17 +37,9 @@ 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 its hand-maintained boot recipe. The caller mounts the + * prompt and registry; each recipe supplies only package-specific seams and + * config, while `dir` participates in the completeness check. */ interface ToolPackage { /** The npm package name, used as the catalog section heading. */ @@ -174,9 +139,8 @@ 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 needs `fs`; the bare provider is sufficient because policy + // changes behavior, not schema shape. await ctx.plugin(LocalFileSystem) await ctx.plugin(ToolFs) }, @@ -249,10 +213,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. + // Mount search and fetch providers so both tools register. Their schemas + // do not depend on provider identity or availability. await ctx.plugin(WebService) await ctx.plugin(WebSearchExa) await ctx.plugin(WebFetchLocal) diff --git a/scripts/jsdoc.ts b/scripts/jsdoc.ts index be6d90adbd..7ac38d3807 100644 --- a/scripts/jsdoc.ts +++ b/scripts/jsdoc.ts @@ -1,13 +1,6 @@ /** - * Shared JSDoc parsing and completeness-check helpers for the documentation - * gates: the cordis and persistence catalog generators - * (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`), - * the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and - * the export-surface gate (`scripts/verify-export-jsdoc.ts`). 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 checks for the Cordis, persistence, + * and config catalogs and the export-surface gate. */ import ts from 'typescript' @@ -29,14 +22,9 @@ 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 and an optional `@mode`. Prose + * ends at the first block tag, paragraphs collapse to one line, bullet items + * remain separate lines, and `{@link X}` renders as `X`. * @param raw - the raw comment text including the JSDoc delimiters. * @returns the collapsed description prose, parsed valid `@mode` (or null), * and whether any `@mode` tag was present. @@ -94,13 +82,8 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMo } /** - * 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 `@param` and `@returns` descriptions, including continuation lines. + * Parameter separators are optional and `[optional]` names unwrap. * @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). @@ -137,17 +120,15 @@ 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. + * Require a non-empty tag for each non-exempt identifier parameter, reject + * binding-pattern parameters, and reject stale tags. Exempt parameters may + * still be documented. * @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 binding-pattern 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 whose tag is optional, such as `this` or waterfall `next`. * @param violations - the aggregate list violations append to. */ export function checkParams( @@ -177,11 +158,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. Void returns may still carry an + * optional tag, for example to document resolution timing. * @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..bce8c31dd4 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -7,10 +7,8 @@ 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. +// Discover harness packages at packages//; group containers, +// examples, and private vendored sources are not package targets. 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..2d2b5ac84c 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -1,19 +1,9 @@ /** - * 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. + * Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive + * from path, H1, and filename date and sort deterministically. Import is pure. */ import { readFileSync, readdirSync } from 'node:fs' diff --git a/scripts/verify-doc-budgets.ts b/scripts/verify-doc-budgets.ts index 1ace894410..854d8d105f 100644 --- a/scripts/verify-doc-budgets.ts +++ b/scripts/verify-doc-budgets.ts @@ -1,30 +1,9 @@ /** - * 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. + * Only listed standing docs are budgeted. Ceilings ratchet down with at least + * 5% headroom; raising one requires the justification defined in + * `docs/AGENTS.md`. */ import { existsSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index 4f07638ab3..e1885f7b6e 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -1,29 +1,7 @@ /** - * 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, checks matching string literals too, + * and excludes built declarations and vendored source. */ import { existsSync } from 'node:fs' @@ -39,12 +17,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 /** Find every broken `docs/….md` reference in one TypeScript file. */ diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 779888ab90..44729e73f0 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -1,77 +1,10 @@ /** - * 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. Inline callable types, overload + * signatures, namespace members, and public class members are included; + * framework slots, constructors, inherited contracts, augmentations, and source + * re-exports keep their docs at the declaring contract. Unknown forms fail closed. */ import { existsSync, globSync } from 'node:fs' @@ -141,12 +74,8 @@ function unwrapExpression(e: ts.Expression): ts.Expression { } /** - * Classify a declarator's type annotation for the function contract: an - * inline function type or a type literal that is EXACTLY one call signature - * is the surface signature itself; a literal mixing call/construct - * signatures with anything else cannot be classified syntactically and is - * refused (fail closed — extract a named type); everything else is a plain - * value shape. + * Classify inline callable annotations. Mixed callable literals fail closed; + * other annotations are ordinary value shapes. * @param type - the declarator's type annotation. * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. */ @@ -162,29 +91,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 +238,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 +263,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 +358,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 +410,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 +464,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 9627f92698..f0d61feb6a 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -1,35 +1,8 @@ /** - * 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 resolution against the source file. The checker never rewrites, + * and symlinked instruction files are deduped. */ import { existsSync, readFileSync } from 'node:fs' @@ -40,10 +13,7 @@ import { uniqueRepoFiles } from './repo-files.ts' 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 7e2f109d6a..20586ec777 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -1,30 +1,8 @@ /** - * 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—including those in lists and blockquotes—from + * multiline structural nodes. The checker never rewrites; symlinked instruction + * files are deduped. The owning convention is in `docs/AGENTS.md`. */ import { readFileSync } from 'node:fs' @@ -70,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 false } }) diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 3f9af495b3..c487c2fd5c 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -1,15 +1,7 @@ /** - * Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's - * own parser. Markdown link/type/code gates can say a diagram block exists and - * is linked, but only Mermaid can catch syntax errors that GitHub would fail to - * render. - * - * Scope matches the Markdown link gate so any Mermaid diagram in repo-authored - * docs is checked: README.md, README.zh.md, docs/** /*.md, - * packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md, - * packages/AGENTS.md, and .agents/skills/** /*.md. - * - * Run: `tsx scripts/verify-mermaid.ts`. + * Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence + * checks cannot. Scope intentionally matches the Markdown link gate, including standing docs, + * package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`. */ import { globSync, readFileSync, realpathSync } from 'node:fs' diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index c186c93fd0..bf03da4247 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, readdirSync } from 'node:fs' @@ -92,36 +58,22 @@ const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g function isDriftedPackageReference(ref: string): boolean { if (existsSync(resolve(root, ref))) return false - // 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. + // Ignore unbuilt `lib/` paths only under an existing depth-two package root: + // CI runs this gate before build, while stale group-less paths must still fail. const parts = ref.split('/') const libAt = parts.indexOf('lib') if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false - // Only a stale path to a REAL (moved) package is a violation; a segment - // matching a live package name is the drift signal. + // A missing reference is drift only when a path segment names a live package. return ref.split('/').slice(1).some(segment => packageNames.has(segment)) } -/** - * Find every DRIFTED `packages/…` reference in one file: a token that does not - * resolve on disk AND names a real package in one of its segments (so it is a - * moved path, not a typo or a not-yet-existing package). The same real-package - * test also screens out a bare `packages` (no segment) and illustrative - * skeletons whose segment is not a package. - */ +/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */ function findViolations(absPath: string): Violation[] { return findReferenceViolations( root, absPath, PKG_REF, - // Trim a trailing path separator or sentence punctuation that the greedy - // class may have swallowed (`packages/core/tools.` / `…/tools/`). + // Remove trailing separators or sentence punctuation matched greedily. ref => ref.replace(/[./]+$/, ''), isDriftedPackageReference, ) diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index 045bf3386e..2b995aab6b 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -1,35 +1,8 @@ /** - * Doc-sync gate: every package README carries the standard - * `## Known Limitations and Deferred Work` section — the per-package home for - * consumer-visible gaps and consciously postponed work that the - * [documentation standard](../docs/AGENTS.md) assigns to the package-README - * tier. One canonical heading instead of per-package variants ("Limitations", - * "What is NOT here", …) keeps the section greppable across the repo and makes - * its absence a gate failure rather than an oversight. - * - * A package with genuinely nothing to declare is listed in NO_LIMITATIONS - * below and must NOT carry the section — an empty section invites boilerplate, - * and a whitelisted package that gains real limitations leaves the whitelist - * in the same change. Whitelist entries are validated against the scanned - * package set, so a rename or removal fails loud instead of silently - * un-gating a README. - * - * The package set comes from `packages///package.json`, so a manifest with no - * sibling README fails instead of escaping a README-only glob. Checks, per - * package README (fenced code and HTML comments excluded): - * 1. Non-whitelisted: exactly one limitations-like heading, byte-equal to the - * canonical h2, with at least one top-level `- ` bullet before the next - * heading. - * 2. Whitelisted: no limitations-like heading at all. - * 3. Every whitelist entry names a scanned package. - * - * "Limitations-like" also matches near-miss headings at any level ("known - * limitations", "deferred work", "what is not here", a heading starting with - * "limitations"/"deferred") so a drifted heading cannot impersonate the - * canonical section and a second competing section cannot coexist with it. - * - * Checker, not fixer: it reports and never rewrites. - * Run: `tsx scripts/verify-package-readme-limitations.ts`. + * Doc-sync gate for the canonical package-README limitations section. It scans + * package manifests, rejects missing or variant sections, and requires one + * top-level bullet; audited packages in {@link NO_LIMITATIONS} must omit it. + * See the [limitations RFC](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md). */ import { existsSync, globSync, readFileSync } from 'node:fs' @@ -41,11 +14,7 @@ const root = resolve(import.meta.dirname, '..') /** The one canonical section heading, required verbatim as an h2. */ const CANONICAL = '## Known Limitations and Deferred Work' -/** - * Packages with genuinely no known limitations or deferred work (keyed by - * package directory relative to the repo root). Their READMEs must NOT carry - * the section; adding one moves the package off this list in the same change. - */ +/** Packages audited as having no limitations section, keyed by repo-relative directory. */ const NO_LIMITATIONS: Readonly> = { 'packages/util/brand': 'Type-only nominal-branding primitive with no runtime behavior or deferred work.', } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index de265b23ad..dc8d11a6c2 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -1,13 +1,8 @@ /** - * Doc-sync gate: require every workspace package README to explain its exact - * model-visible context surface and token behavior. Most packages require the - * canonical context-surface blocks with optional nested verbatim H4 blocks. - * Direct system-prompt surfaces must contain exact `markdown` blocks, - * tool-schema surfaces must link generated catalog sections, local subsection - * links are rejected, and audited package classifications either use one - * concise sentence or omit the section entirely. - * - * Run: `tsx scripts/verify-package-readme-model-experience.ts`. + * Doc-sync gate for package README Model Experience sections. It validates + * audited package classifications, context-surface fields, package-owned text + * blocks, generated-catalog links, and final-section order. See the + * [Model Experience RFC](../docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md). */ import { existsSync, globSync, readFileSync } from 'node:fs' @@ -52,7 +47,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, - 'packages/sandbox/sandbox': { kind: 'indirect', reason: 'Sandbox consumers render enforcement and availability facts.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 2e270c17f8..0511efd2e7 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,31 +1,8 @@ /** - * 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`; the closed classification + * contract lives in `docs/rfc/README.md`. */ import { readFileSync } from 'node:fs' diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts index 99347e4764..341f184abb 100644 --- a/scripts/verify-rfc-format.ts +++ b/scripts/verify-rfc-format.ts @@ -1,31 +1,8 @@ /** - * 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. Exact format and + * grandfathering rules live in `docs/rfc/README.md`. */ import { readFileSync } from 'node:fs' @@ -65,9 +42,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-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 31d2b9e3bf..34feb3510b 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -1,10 +1,7 @@ /** - * Verify that the Python single-exe deploy manifest explicitly supplies every - * required workspace peer of every workspace package in its dependency graph. - * - * `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete - * runtime root. Keeping the peer at the root also prevents a successful build - * from producing an executable that fails only when Cordis loads the plugin. + * Verify that the executable deploy manifest supplies every required workspace + * peer in its dependency graph. With auto peer installation disabled, a missing + * root peer can otherwise fail only when Cordis loads the packaged plugin. */ import { readFile, readdir } from 'node:fs/promises' import { join, resolve } from 'node:path' diff --git a/scripts/verify-scoped-dispatch.ts b/scripts/verify-scoped-dispatch.ts index 214d55f7dc..64e2333340 100644 --- a/scripts/verify-scoped-dispatch.ts +++ b/scripts/verify-scoped-dispatch.ts @@ -1,19 +1,10 @@ /** - * 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). + * Registry-subject notifications are intentionally unfiltered and belong in neither set. */ import { globSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 8297d13fe8..eaeacb34d2 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -1,49 +1,10 @@ /** - * 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. Date-named documents (`yyyy-mm-dd-*.md`, i.e. RFCs) dated on/after the - * manifest's `requiredSince` merge bilingual — the frontier for NEW - * documents, independent of the `required` back-catalog list. - * 4. `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. Required files and date-named docs + * at or after `requiredSince` must be paired; excluded docs may have neither a + * counterpart nor sidecar. `--list` reports state and `--write` records both + * sides after human review. Translation quality remains a review responsibility. + * See `docs/i18n/README.md` for the owning contract. */ import { createHash } from 'node:crypto' diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 85ccd642d9..729fd632cd 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. - */ +/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */ 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,11 @@ 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 so prose-only edits do not drift + * structural copies. This is intentionally not a general tokenizer: repo type + * declarations do not contain comment delimiters inside string literals. + */ function normalize(code: string): string { return code .replace(/\/\*[\s\S]*?\*\//g, '') @@ -72,8 +47,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..5efebe5b0b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' /** - * JS bundling for all workspace packages (vendor and the packages hierarchy). + * JS bundling for vendored Cordis and Harness TypeScript packages. * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown * reads only the emitted JS under lib/types and writes lib/index.* runtime * bundles. Declarations are NOT produced here, hence `dts: false`. @@ -10,10 +10,9 @@ 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 keep bundling to vendored Cordis and the TypeScript package tree; + // `workspace: true` would discover package manifests outside that bundle set. Landlock + // platform packages contain only a prebuilt native binary, so they have no JS entry. workspace: ['vendor/*', 'packages/*/*'], entry: ['lib/types/index.js'], outDir: 'lib', diff --git a/vitest.config.ts b/vitest.config.ts index 11d1454b08..30af2d3612 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,21 +2,9 @@ import tsconfigPaths from 'vite-tsconfig-paths' 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. + // Native path resolution reads each package's nearest tsconfig, but only the root defines + // workspace paths. Keep this plugin pinned to the root map so unbuilt bare package imports resolve + // to source; native resolution would fall through to absent `lib/` outputs. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], @@ -26,16 +14,8 @@ 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. + // Types-only files have no runtime coverage. Importing self-executing bins/workers would boot + // them inside the unit process, so real subprocess/Worker tests cover their thin entry glue. 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..adc8b3f3b2 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,18 +1,9 @@ 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 +// Real-API suite, separate because it spends tokens. Each test self-skips without +// DEEPSEEK_API_KEY for keyless CI; the credentialed workflow preflights the secret. Values may come +// from the environment or gitignored root `.env`, with optional DEEPSEEK_BASE_URL. 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..903cd3e129 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,20 +1,10 @@ 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. +// Replay is the keyless default: boot the real ACP subprocess from recorded model scripts and diff +// normalized transcript plus persisted-log goldens. `record` calls the real API and updates fixtures +// and goldens; `refresh` replays committed scripts and updates only current goldens. Replay/refresh +// never load `.env`; only record reads a key from the environment or gitignored root `.env`. if (process.env.DSH_SNAPSHOT === 'record') { try { process.loadEnvFile(new URL('.env', import.meta.url).pathname)