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 1494d3fcf8..57e552cbeb 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/ui/jsonrpc/tests/built-scope-carrier.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 0eb7f1fe55..a625120c92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,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. @@ -113,7 +113,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 @@ -141,7 +141,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 bd2474c8b7..2bcb5e8be0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -63,22 +63,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 +103,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` @@ -131,7 +128,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:361`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:358`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -151,7 +148,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` @@ -178,7 +175,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` @@ -214,7 +211,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` @@ -267,7 +264,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` @@ -278,7 +275,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. @@ -303,7 +300,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` @@ -313,7 +310,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. @@ -328,43 +325,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` @@ -391,7 +372,7 @@ export interface Config { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:43`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:30`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -444,7 +425,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -461,10 +442,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 @@ -479,7 +457,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` @@ -519,20 +497,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[] /** @@ -544,21 +512,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` @@ -576,7 +535,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` @@ -611,7 +570,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` @@ -739,20 +698,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` @@ -766,7 +716,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` @@ -801,7 +751,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` @@ -815,7 +765,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` @@ -823,48 +773,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` @@ -882,7 +804,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` @@ -902,7 +824,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` @@ -977,7 +899,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` @@ -999,7 +921,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` @@ -1015,7 +937,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` @@ -1025,18 +947,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 } @@ -1045,7 +959,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:401`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:308`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1076,7 +990,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` @@ -1095,7 +1009,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` @@ -1145,7 +1059,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` @@ -1217,7 +1131,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 1db5e85828..390dcfd71c 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 f913b95c36..e00065dcba 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -185,13 +185,13 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:356`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.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 @@ -199,13 +199,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> @@ -213,11 +213,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 @@ -225,11 +225,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 @@ -237,7 +237,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/*` @@ -257,27 +257,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 @@ -285,17 +285,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/*` @@ -365,27 +365,23 @@ Source: [`packages/subagent/subagent/src/index.ts:83`](../../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/*` @@ -397,11 +393,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 @@ -409,11 +405,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 @@ -421,11 +417,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 @@ -433,11 +429,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 @@ -445,7 +441,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/*` @@ -457,7 +453,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 @@ -467,7 +463,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 @@ -477,7 +473,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 @@ -487,7 +483,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 @@ -497,7 +493,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 @@ -507,7 +503,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 83b436abf1..571f628be8 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:376`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:373`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -44,9 +44,7 @@ Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/i ## `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 @@ -54,18 +52,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 @@ -81,18 +72,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 @@ -100,18 +84,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 @@ -120,20 +97,11 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [Message](../core-data-structures/core.md) -Source: [`packages/compact/compact/src/index.ts:66`](../../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 @@ -147,7 +115,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:78`](../../packages/fs/fs/src/index.ts) ## `ctx.llm` — `LlmService` @@ -161,11 +129,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 @@ -176,17 +144,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 @@ -194,18 +156,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 @@ -216,7 +171,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:68`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` @@ -247,7 +202,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:612`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:582`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -277,7 +232,7 @@ Source: [`packages/subagent/subagent/src/index.ts:124`](../../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 @@ -286,13 +241,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 @@ -305,7 +258,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:493`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:364`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -338,24 +291,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 0c1fa072ab..a04739d8ba 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`): `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 de350df9f3..d253166fcf 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -42,6 +42,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(start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` from `agent.session` 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/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 7b57789a7c..18cb22c32d 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 { @@ -80,7 +80,7 @@ A local run MUST publish an ordinary child agent/session before `start()` fulfil ## 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 { @@ -91,11 +91,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 d7e0464f01..08c289e5e5 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. @@ -184,7 +184,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 74c76d03cb..4e2a14df8b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:356`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../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:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | @@ -21,34 +21,34 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../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:576`](../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) | +| `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:91`](../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:67`](../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:73`](../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:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `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 0fdefd6b00..22534a9bfa 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/*` @@ -75,13 +75,13 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/ #### `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/*` @@ -141,29 +141,29 @@ 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/*` @@ -259,7 +259,7 @@ Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/ #### `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 } @@ -267,7 +267,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 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 fb272c5df9..77f521834c 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`, the shared agent/session `SessionId`) — 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 824e59a172..3bd3c3fdf1 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 registers the fresh agent under the exact resumed id. 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-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 61e292aaeb..23ee2d72bd 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-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 f785851237..b38777be38 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-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 362f5cb5e8..4f31c48623 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-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 a708225482..06bc6daf6a 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-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 6961883318..c258d72c68 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 two listeners and holds state in a `WeakMap` keyed by the live `Agent` object — 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; weak object keys also make a disposal-only cleanup listener unnecessary. @@ -28,7 +28,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 @@ -46,7 +46,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 @@ -63,7 +65,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 0899065b71..4d592af42b 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-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 0327662d3d..fd87377fb9 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/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-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 a091203da9..8e4f4cce13 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). These lists are small today, but every new package creates another manual synchronization point. +Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package 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/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 a1a841296a..e0292071ec 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.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 2e05bef069..1ce40e7252 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 }, - // 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, @@ -90,31 +87,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 }, @@ -126,11 +107,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/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index dbe98ab358..47dc9b4abd 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -12,21 +12,10 @@ import { import { cleanupAcpExampleTest } from './cleanup.ts' /** - * 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 AGENT: AgentUnderTest = { @@ -49,9 +38,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' }] }] }, })) @@ -74,12 +62,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 7247d4ae37..0a93b649da 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 e612fb942a..cbaec45f9f 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 a30f9e4a46..0d8e018ae5 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 22446f0dc3..9e6b26d8fe 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -17,14 +17,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 a0e71aacc4..a2c9b1534d 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -7,25 +7,11 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. import { SessionId } from '@deepseek-ai/dsh-session' /** - * 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 ffe9a290eb..a1fea1b8db 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 c4c48645e1..7796fffaaa 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/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 5c224997ef..47bcff9241 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. The protected `estimateContentTokens()` and `summarize()` methods are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the private retention/pressure accounting 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/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-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 52a2ecd134..25a375e9c8 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -15,24 +15,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 @@ -133,14 +119,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 index = nodes.indexOf(cp.seq) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 1c014f484a..e4baec6551 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -1,24 +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 - * an agent-owned `Session` and the durable summary event uses 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 */ @@ -43,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) { @@ -70,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( @@ -118,37 +62,18 @@ 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 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 whose session is mutated and whose routing - * options are used by 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 the replaced range and its token accounting. The durable - * `compact/summary` event owns the summary and bookkeeping-event identity. + * @param start - first surface seq, inclusive. + * @param end - last surface seq, inclusive. + * @param agent - context whose session is mutated and whose routing options guide summarization. + * @param signal - optional cancellation; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the replaced range and token accounting; the durable event owns the summary. */ abstract compactRegion( start: number, 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 78e94173a3..0d3d35ace8 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 1092a7ef04..0edbdf4180 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -79,14 +79,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', @@ -101,7 +101,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', ], @@ -116,7 +116,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', @@ -138,7 +138,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', @@ -155,7 +155,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', @@ -208,7 +208,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', @@ -218,7 +218,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', @@ -248,7 +248,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'workflows', - summary: 'Abstract workflow execution service.', + summary: 'Workflow execution seam.', methods: [ 'abstract start(request: WorkflowStartRequest): WorkflowRun', ], @@ -345,25 +345,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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', @@ -375,25 +375,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', @@ -435,13 +435,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', @@ -453,25 +453,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 23d0614bb2..7e4f5c5d35 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 058191c88f..573728583d 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -191,15 +191,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/src/agent.ts b/packages/core/agent-loop/src/agent.ts index d54baab597..56fbffdbc9 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 ff0c692928..8d9a184cc7 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -98,12 +98,9 @@ function signalAbortError(id: SessionId, 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/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 56d6489fbe..08b744c6a2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -166,10 +166,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/) @@ -251,26 +249,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, SessionId('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') }) @@ -365,10 +357,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) @@ -394,11 +384,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 @@ -416,10 +403,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 82b1f6c58c..152b6852b2 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 */ @@ -96,10 +93,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('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(SessionId('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(SessionId('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) => { @@ -399,10 +388,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('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) => { @@ -420,11 +407,7 @@ describe('Agent.cancel()', () => { }) it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => { - // The window-1 early-resolve race has a window-2 twin: a synchronous - // agent/status('running') listener cancels the about-to-run turn AND queues a - // replacement. window 2 must NOT settle waiters (via setStatus('idle')) while - // the replacement is still queued-and-unrun — it must fall through and run it, - // so whenIdle() resolves on the replacement turn's running→idle, not before. + // Cancellation must not settle idle while replacement work remains queued. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) @@ -450,11 +433,8 @@ describe('Agent.cancel()', () => { }) it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => { - // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() - // clears A; prompt B is queued BEFORE the loop resumes from the idle wait. - // The window-1 cancel branch must NOT settle the waiter while B is still - // queued-and-unrun — whenIdle() must wait for B's turn to actually run and - // settle (the quiescence contract), not resolve before B's first event. + // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A; + // prompt B is queued before the loop resumes from the idle wait. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) @@ -464,9 +444,8 @@ describe('Agent.cancel()', () => { agent.cancel('drop A') // arms marker, clears A send(agent, 'B') // B races in before the loop resumes - // whenIdle() must resolve only AFTER B's turn fully ran — by which point B's - // user message and a turn/end are in the log. (Before the fix it resolved - // immediately, with zero events, then B ran afterward.) + // whenIdle() must resolve only after B's turn fully ran — by which point B's user message + // and a turn/end are in the log. await idle expect(userTexts(agent)).toContain('B') expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index ceaf6597cb..096a6eff9e 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -351,9 +351,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 942199e9eb..44e0a2963d 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -40,7 +40,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) @@ -90,7 +90,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 @@ -142,7 +142,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'), @@ -169,21 +169,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'), @@ -201,12 +187,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) @@ -264,7 +248,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) @@ -319,7 +303,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) @@ -366,7 +350,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) @@ -525,7 +509,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) @@ -563,7 +547,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', { @@ -581,12 +565,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' } }, ] @@ -607,7 +588,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) }) @@ -653,10 +634,7 @@ describe('step boundary publication order', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('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 @@ -679,10 +657,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) @@ -834,9 +809,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) @@ -895,9 +868,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 @@ -909,9 +880,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') }) @@ -1002,10 +970,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) @@ -1113,11 +1078,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) @@ -1144,12 +1105,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)) @@ -1164,7 +1121,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() @@ -1182,28 +1139,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 () => { @@ -1260,9 +1211,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)) @@ -1313,8 +1263,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 4e82e398f1..c46802efd7 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -226,9 +226,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 8c95be0d82..6b6547d106 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -113,14 +113,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(SessionId('a1'), { model: 'mock' }) @@ -185,9 +179,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(SessionId('a1'), { model: 'mock' }) @@ -511,14 +504,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') }) @@ -614,11 +606,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 c4e49e0fa5..59f3b90957 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(SessionId('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(SessionId('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/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 7eddb6be39..c8e043d1ae 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -10,15 +10,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/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index f5809575ab..21cfd7b4f2 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -451,10 +451,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({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent @@ -476,10 +474,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({ 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 610e077a67..9f4b2d5996 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -926,11 +926,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 1085f01a4c..7cc14056bd 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' @@ -94,11 +93,7 @@ describe('loop-level canonical tool order', () => { }) it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => { - // The assemble rejection escapes to runTurn's outer catch: the open turn - // closes with an `error` reason (agent/error mirrors it), no step opens, - // no request/header is logged, the adapter never sees a request, and the - // agent returns to idle — a misconfigured deployment fails every turn - // deterministically instead of silently reordering nothing. + // Unknown tool order fails before step or request creation and returns the agent to idle. const adapter = new MockAdapter([textResponse('never sent')]) const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST]) registerNamed(ctx, 'alpha') diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 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/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/src/index.ts b/packages/core/session/src/index.ts index c1b3a0b48f..99c63baf7b 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/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index c65755ce8f..8764896a45 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 replace (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 20746260d5..41d6e38438 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/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 d9d19c7f15..9a8a36face 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, public config: Config) { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) - // The harness-owned openers. They live HERE (not on the loop plugin) so a - // deployment that swaps in a different loop keeps them: the identity is a - // harness fact stated ahead of everything, and the persona is the - // deployment's config, one section of the full prompt, never the whole. - // An empty persona still RESERVES the section name (one owner — a plugin - // re-registering it throws); renderPrompt drops the empty text. + // 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 7f01ca91fb..5b2a81528b 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 08edb8d8ae..988c6a0268 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 } /** @@ -308,14 +238,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 /** @@ -328,19 +252,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' } @@ -348,16 +263,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 } @@ -400,35 +307,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. */ @@ -469,26 +358,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'] @@ -526,13 +397,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)) @@ -542,22 +407,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) @@ -595,23 +446,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) @@ -632,52 +470,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) @@ -715,12 +527,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 } @@ -842,15 +649,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. */ @@ -869,27 +669,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 2339657ec8..283ab4d370 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -349,10 +349,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 @@ -576,11 +574,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 e74766c699..b171817e35 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -703,16 +703,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) => { @@ -999,20 +992,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 8dcbe270b6..c92266128f 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/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/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 0b41fd0d10..b48095b548 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -184,9 +184,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`) @@ -385,10 +384,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 }] }] }) @@ -460,9 +457,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 }] }] }) @@ -592,10 +588,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') @@ -629,11 +623,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') @@ -684,11 +675,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 cd715d4280..e624b8b95f 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -16,10 +16,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[] = [] @@ -91,28 +90,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(SessionId('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') }) @@ -120,7 +114,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')]) @@ -128,7 +121,6 @@ describe('hooks-codex bridge', () => { const agent = ctx.agentLoop.create(SessionId('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) }) @@ -144,10 +136,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')]) @@ -173,10 +163,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() @@ -195,14 +183,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 75b04d4c57..d397afffce 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -71,9 +71,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')]) @@ -439,11 +438,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/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 b4dd564346..1d13ff424e 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,16 +80,13 @@ 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. /* jscpd:ignore-end */ // --- 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 @@ -122,11 +94,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) @@ -135,10 +104,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) @@ -174,9 +141,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) @@ -197,10 +162,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)`) @@ -217,28 +179,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 { @@ -257,11 +213,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) @@ -323,10 +277,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' @@ -347,9 +299,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 7c5febb88f..7371d532fd 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 { @@ -381,10 +377,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]) }) @@ -504,9 +499,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) }) @@ -526,10 +520,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. let b!: Session await ctx.plugin(Object.assign((inner: Context) => { b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) @@ -546,13 +538,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 }) @@ -617,9 +605,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() @@ -630,11 +617,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) @@ -752,10 +736,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 72419ef51e..7d23292e9d 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. // --- PersistenceBackend hooks (the SQLite storage primitives) --- 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 15d2a44492..8f8e3a274d 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 @@ -292,11 +288,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') @@ -313,11 +307,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 e35a6e2e41..a3c24b32bf 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 */ @@ -29,16 +10,9 @@ import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } fro import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' /** - * 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 @@ -111,16 +85,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 /** @@ -188,13 +155,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. - * - * Flush is the public observation boundary for initialization; callers do - * not inspect this bookkeeping directly. + * Init promises keyed by live session object, preventing an id-reusing + * replacement from inheriting stale initialization. Flush is the public + * observation boundary; callers do not inspect this bookkeeping directly. */ private inits = new Map>() @@ -205,16 +168,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')) @@ -301,32 +259,20 @@ export class PersistenceCoordinator { this.assertVersion(meta) assertSupportedEvents(events, id) - // 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 ec252fc027..3e6c9c21ed 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 */ @@ -38,32 +22,10 @@ declare module 'cordis' { } /** - * 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) { @@ -91,29 +53,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 c4cc714646..f6692b9175 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 */ @@ -38,25 +21,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 @@ -118,10 +88,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 { @@ -207,10 +176,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) @@ -218,9 +187,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')) @@ -231,7 +197,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() @@ -298,10 +263,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' } } }) @@ -393,9 +357,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 f8ea43fd05..6f6a230a17 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -36,18 +36,10 @@ function legacyFallbackHeader(seq = 0): SessionEvent { 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'] @@ -103,8 +95,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[]) @@ -137,12 +128,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 e135beb210..b7b10ecb97 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 { SessionId } from '@deepseek-ai/dsh-session' 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. @@ -196,24 +155,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // each other or with a local agent that happens to use the same session id. const id = SessionId(randomUUID()) - // 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, @@ -222,12 +173,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 => ({ @@ -263,28 +209,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 */ }) } @@ -333,12 +265,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: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) @@ -349,23 +276,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 { @@ -381,15 +299,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/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 360098e369..0c62d26df9 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -136,10 +136,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() @@ -184,12 +183,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 93da5b1d51..6653ab3417 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -37,12 +37,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() @@ -217,11 +214,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 @@ -245,10 +240,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 06aa40ca9f..353a0b7900 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -153,11 +153,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([]) @@ -166,10 +163,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 37e0b28323..67fcc99600 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -12,16 +12,12 @@ import type { SessionId } from '@deepseek-ai/dsh-session' 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). */ @@ -61,14 +57,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 /** @@ -137,14 +128,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 { /** @@ -193,15 +179,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/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 778353f762..5b05567a6b 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/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 9b0760213c..6b25e7d9b3 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 6adb0d4268..80e38325b7 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -537,11 +537,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 7aa218526c..ac659bf7b1 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -17,21 +17,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 = { @@ -43,12 +36,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, expectedHeaderChanges: 1, headerClass: 'main' }, { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8efaf88cdd..6386f6ed28 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 5decd81af2..654a477874 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 bf7bcbe2fb..c2e94c521f 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 b5e7180782..aec0fd7db1 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -107,10 +107,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 2e9ed0d635..70ead41e1b 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 */ @@ -71,22 +44,9 @@ export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecut } /** - * Register the tool-call timeout enforcer. For a tool whose {@link ToolDefinition} - * declares `timeoutMs`, the listener arms a {@link deadline} on the caller's - * `exec.signal`, swaps it onto `exec` for the downstream dispatch (cordis - * `next()` ignores passed arguments, so a wrapper mutates the shared `exec` in - * place), restores the original signal afterward so `tools/post-execute` sees the - * caller's own signal, and replaces the result with {@link toolTimeoutResult} - * when its own timer fired. A tool that declares no budget delegates untouched. - * - * The budget source is the tool's own declaration read from the registry - * (`ctx.tools.get(exec.name, exec.agent)?.timeoutMs`), NOT a plugin config map — - * `exec.name` is the tool being dispatched, so the lookup always resolves and - * there is no mistypable tool name and no unknown-name path to warn or throw - * about. Resolution goes through the CALLER's visible view (the `exec.agent` - * scope), exactly like dispatch itself: a scoped tool's own `timeoutMs` governs - * its calls, and a global name-twin's budget is never misapplied to a shadowing - * per-agent variant. + * Register the 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 b426b6760c..2059bf13e8 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -150,10 +150,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/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/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index fb39b2a0de..3698b0a71c 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,7 +25,6 @@ class SandboxedLocalExecutor extends LocalBashExecutor { } } -/** The exact option payload the bridge advertises (pinned verbatim). */ function permissionOption(currentValue: string): object { return { id: 'permission', @@ -43,8 +34,8 @@ function permissionOption(currentValue: string): object { 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/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 efc9399c75..b8de5b8557 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -57,12 +57,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, @@ -94,10 +90,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, @@ -168,11 +162,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: [] }) @@ -197,10 +188,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({ @@ -236,9 +226,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 000b80336d..15c2415449 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(SessionId(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/package.json b/packages/ui/jsonrpc/package.json index bf4fe39118..04705644ae 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/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/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 4d555f5965..d93e1d301b 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -11,20 +11,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() @@ -181,14 +171,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/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 45d7fa91aa..69a243a854 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/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 ac7112a0f0..08bc8171c3 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 7dbdccbb43..a14093e506 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 09fb606d69..bb8220ce63 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: SessionId('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 35ea4fb053..0a37a37f0d 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -107,16 +107,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 3476399438..d3e2a5df26 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' @@ -579,13 +566,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' } @@ -632,13 +614,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) @@ -765,7 +742,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 02e0199fbb..dd2ac16df4 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)