Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into agent-request-messages

docs/core-data-structures/core.md: combined master's canonical tool-order
wording (#196) with this branch's request-advice envelope + wire-order
paragraphs.
Yichen Jiang 2 месяцев назад
Родитель
Сommit
2cbdeb0872
77 измененных файлов с 2644 добавлено и 161 удалено
  1. 103 0
      .agents/skills/dsh-pre-push-checks/SKILL.md
  2. 4 0
      .agents/skills/dsh-pre-push-checks/agents/openai.yaml
  3. 2 2
      .github/workflows/ci.yml
  4. 1 0
      .github/workflows/e2e.yml
  5. 2 1
      AGENTS.md
  6. 1 1
      docs/AGENTS.md
  7. 45 13
      docs/config-catalog.md
  8. 3 3
      docs/cordis-catalog/events.md
  9. 3 3
      docs/cordis-catalog/services.md
  10. 1 1
      docs/core-data-structures/core.md
  11. 2 2
      docs/core-data-structures/subagent.md
  12. 34 0
      docs/core-data-structures/tools.md
  13. 1 1
      docs/core-data-structures/web.md
  14. 2 2
      docs/development.i18n.yaml
  15. 4 4
      docs/development.md
  16. 4 4
      docs/development.zh.md
  17. 3 3
      docs/event-producer-consumer.md
  18. 3 1
      docs/module-graph.md
  19. 2 0
      docs/rfc/INDEX.md
  20. 1 1
      docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
  21. 49 0
      docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md
  22. 1 1
      docs/rfc/implemented/process/2026-06-11-quality-gates.md
  23. 37 0
      docs/rfc/implemented/process/2026-07-06-node-engine-floor.md
  24. 1 1
      docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md
  25. 0 0
      examples/acp-agent/tests/snapshots/text-turn/session.jsonl
  26. 2 2
      package.json
  27. 2 2
      packages/core/agent-core/README.md
  28. 20 12
      packages/core/agent-core/src/index.ts
  29. 18 0
      packages/core/agent-core/tests/agent-core.spec.ts
  30. 117 0
      packages/core/agent-loop/tests/tool-order.spec.ts
  31. 3 2
      packages/core/system-prompt/README.md
  32. 124 13
      packages/core/system-prompt/src/index.ts
  33. 115 0
      packages/core/system-prompt/tests/tool-order.spec.ts
  34. 6 0
      packages/core/tools/README.md
  35. 10 0
      packages/core/tools/src/index.ts
  36. 345 0
      packages/core/tools/src/json-schema.ts
  37. 304 0
      packages/core/tools/tests/json-schema.spec.ts
  38. 1 1
      packages/session-persistence/session-persistence-sqlite/README.md
  39. 1 1
      packages/subagent/subagent-fork/README.md
  40. 8 3
      packages/subagent/subagent-fork/src/index.ts
  41. 24 3
      packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
  42. 17 4
      packages/subagent/subagent-inprocess/README.md
  43. 2 0
      packages/subagent/subagent-inprocess/package.json
  44. 69 5
      packages/subagent/subagent-inprocess/src/index.ts
  45. 312 0
      packages/subagent/subagent-inprocess/src/structured.ts
  46. 606 0
      packages/subagent/subagent-inprocess/tests/structured.spec.ts
  47. 6 0
      packages/subagent/subagent-inprocess/tsconfig.json
  48. 1 1
      packages/subagent/subagent-spawn/README.md
  49. 16 4
      packages/subagent/subagent-spawn/src/index.ts
  50. 52 4
      packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
  51. 9 5
      packages/subagent/subagent/src/types.ts
  52. 2 2
      packages/subagent/subagent/tests/service.spec.ts
  53. 2 2
      packages/support/subagent-mock/tests/subagent-mock.spec.ts
  54. 1 0
      packages/ui/acp-agent/README.md
  55. 1 0
      packages/ui/acp-agent/package.json
  56. 9 1
      packages/ui/acp-agent/src/index.ts
  57. 22 0
      packages/ui/acp-agent/tests/acp-agent.spec.ts
  58. 1 0
      packages/ui/stdio-agent/README.md
  59. 1 0
      packages/ui/stdio-agent/package.json
  60. 9 1
      packages/ui/stdio-agent/src/index.ts
  61. 4 3
      packages/ui/stdio-agent/tests/built-bin.e2e.ts
  62. 22 0
      packages/ui/stdio-agent/tests/stdio-agent.spec.ts
  63. 1 1
      packages/web/web-fetch-local/src/provider.ts
  64. 1 1
      packages/web/web-search-deepseek/src/provider.ts
  65. 1 1
      packages/web/web-search-exa/src/provider.ts
  66. 1 1
      packages/web/web-search-perplexity/src/provider.ts
  67. 46 17
      pnpm-lock.yaml
  68. 1 1
      scripts/doc-budgets.manifest.json
  69. 2 3
      scripts/doc-typecheck.ts
  70. 4 0
      scripts/type-equiv.manifest.json
  71. 2 3
      scripts/verify-doc-refs.ts
  72. 2 3
      scripts/verify-md-links.ts
  73. 2 3
      scripts/verify-md-wrap.ts
  74. 2 3
      scripts/verify-mermaid.ts
  75. 2 3
      scripts/verify-package-paths.ts
  76. 2 3
      scripts/verify-translation-pairing.ts
  77. 2 3
      scripts/verify-type-equiv.ts

+ 103 - 0
.agents/skills/dsh-pre-push-checks/SKILL.md

@@ -0,0 +1,103 @@
+---
+name: dsh-pre-push-checks
+description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes.
+---
+
+# DSH Pre-Push Checks
+
+Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, demo smoke, and built-bin smoke.
+
+## First Steps
+
+1. Confirm the checkout and branch.
+
+```sh
+git status --short --branch
+git rev-parse --show-toplevel
+```
+
+2. Inspect the outgoing diff.
+
+```sh
+git diff --stat
+git diff --name-only origin/$(git branch --show-current)...HEAD
+```
+
+If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch.
+
+3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence.
+
+## Required Baseline
+
+Run these before every non-trivial push:
+
+```sh
+pnpm run typecheck
+pnpm run lint
+pnpm run test:coverage
+```
+
+Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI.
+
+## Add Gates By Touched Surface
+
+Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, RFCs, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages.
+
+Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`.
+
+Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures.
+
+```sh
+pnpm run test:snapshot
+```
+
+Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
+
+```sh
+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
+```
+
+Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.
+
+```sh
+pnpm run test:e2e
+```
+
+Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior.
+
+## Full Local CI Approximation
+
+Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior.
+
+## Handling Failures
+
+If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
+
+If a failure looks environment-specific, prove it:
+
+- Record the exact command, failing test, and platform-specific mismatch.
+- Confirm the relevant non-platform gates pass.
+- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate.
+- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI.
+
+Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass.
+
+## Push Procedure
+
+1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented.
+2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it.
+3. Push normally first so the pre-push hook can run.
+4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response.
+5. After push, verify the remote ref matches local HEAD.
+
+```sh
+git rev-parse HEAD origin/$(git branch --show-current)
+```
+
+For GitHub PRs, check CI after push:
+
+```sh
+gh pr checks
+```
+
+If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good.

+ 4 - 0
.agents/skills/dsh-pre-push-checks/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "DSH Pre-Push Checks"
+  short_description: "Run the right DeepSeek Harness gates before push"
+  default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."

+ 2 - 2
.github/workflows/ci.yml

@@ -95,13 +95,13 @@ jobs:
 
   node-compat:
     runs-on: ubuntu-latest
-    name: node 26
+    name: node ${{ matrix.node }}
     env:
       DSH_GATE_CONCURRENCY: '2'
     strategy:
       fail-fast: false
       matrix:
-        node: [26]
+        node: ['22.19', 24, 26]
     steps:
       - uses: actions/checkout@v6
 

+ 1 - 0
.github/workflows/e2e.yml

@@ -49,6 +49,7 @@ permissions:
 jobs:
   e2e:
     runs-on: ubuntu-latest
+    name: e2e
     # Run on every trusted event. Skip untrusted PRs (forks + Dependabot) where
     # the secret is withheld — they would otherwise hard-fail the preflight.
     if: >-

+ 2 - 1
AGENTS.md

@@ -34,7 +34,7 @@ Per-package map: the group READMEs, indexed from [packages/README.md](packages/R
 ## Commands
 
 ```sh
-pnpm install            # pnpm workspaces, node >= 24
+pnpm install            # pnpm workspaces, node ^22.19 || >=24
 pnpm run test           # vitest unit tests
 pnpm run test:coverage  # THE gating test run: per-file 100% coverage on packages/*/*/src
 pnpm run test:e2e       # real-API tests; self-skip without DEEPSEEK_API_KEY
@@ -91,6 +91,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
 - **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.
 - **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
 - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
 - **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.

+ 1 - 1
docs/AGENTS.md

@@ -16,7 +16,7 @@ Every fact has exactly one home — the tier whose job it is — and every other
 | [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 | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns |
-| [development.md](development.md) | Human-facing setup and daily workflow; a bilingual pair under the [i18n contract](i18n/README.md) | Gate-by-gate enumerations that drift from `package.json` scripts |
+| [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) |
 

+ 45 - 13
docs/config-catalog.md

@@ -40,7 +40,8 @@ Source: [`packages/ui/acp/src/index.ts:115`](../packages/ui/acp/src/index.ts)
  * App config: the swappable per-deployment values. `model` configures the
  * agent template the ACP bridge creates each session's agent from (NOT a
  * pre-created agent — ACP creates agents at `session/new`); `persona` is the
- * deployment persona (forwarded to the system-prompt plugin);
+ * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
+ * the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * `persistenceRoot` is the JSONL backend's directory.
  */
 export interface Config {
@@ -48,12 +49,14 @@ export interface Config {
   model: string
   /** Deployment persona (the system-prompt plugin's `persona` config). */
   persona?: string
+  /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
+  toolOrder?: string[]
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
 }
 ```
 
-Source: [`packages/ui/acp-agent/src/index.ts:48`](../packages/ui/acp-agent/src/index.ts)
+Source: [`packages/ui/acp-agent/src/index.ts:49`](../packages/ui/acp-agent/src/index.ts)
 
 ## `@deepseek-ai/dsh-agent-core`
 
@@ -61,23 +64,26 @@ Source: [`packages/ui/acp-agent/src/index.ts:48`](../packages/ui/acp-agent/src/i
 /**
  * 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` to the system-prompt plugin (the
- * deployment's persona section). Both are optional INPUT here because each
- * owner's schema supplies the default (`[]` / `''`); the schema is the
- * INTERSECTION of the owners' own schemas, so validation and defaulting can
- * never drift from them.
+ * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
+ * plugin (the deployment's persona section and the explicit model-facing tool
+ * order). Every field is optional INPUT here because each owner's schema
+ * supplies the default (`[]` / `''` / absent — lexicographic); the schema is
+ * the INTERSECTION of the owners' own schemas, so validation and defaulting
+ * can never drift from them.
  */
 export interface Config {
   /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
   agents?: AgentLoopConfig['agents']
   /** The deployment persona (see dsh-system-prompt's `Config`). */
   persona?: SystemPromptConfig['persona']
+  /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
+  toolOrder?: SystemPromptConfig['toolOrder']
 }
 ```
 
 Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt)
 
-Source: [`packages/core/agent-core/src/index.ts:68`](../packages/core/agent-core/src/index.ts)
+Source: [`packages/core/agent-core/src/index.ts:69`](../packages/core/agent-core/src/index.ts)
 
 ## `@deepseek-ai/dsh-agent-loop`
 
@@ -407,7 +413,8 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:5
  * App config: the swappable per-demo values, each routed to where the app wires
  * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
  * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
- * the deployment persona (forwarded to the system-prompt plugin);
+ * the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
+ * is the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
  */
 export interface Config {
@@ -415,6 +422,8 @@ export interface Config {
   model: string
   /** Deployment persona (the system-prompt plugin's `persona` config). */
   persona?: string
+  /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
+  toolOrder?: string[]
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
   /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -428,7 +437,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/ui/stdio-agent/src/index.ts:59`](../packages/ui/stdio-agent/src/index.ts)
+Source: [`packages/ui/stdio-agent/src/index.ts:60`](../packages/ui/stdio-agent/src/index.ts)
 
 ## `@deepseek-ai/dsh-subagent-acp`
 
@@ -498,7 +507,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/subagent/subagent-fork/src/index.ts:34`](../packages/subagent/subagent-fork/src/index.ts)
+Source: [`packages/subagent/subagent-fork/src/index.ts:38`](../packages/subagent/subagent-fork/src/index.ts)
 
 ## `@deepseek-ai/dsh-subagent-mock`
 
@@ -545,7 +554,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/subagent/subagent-spawn/src/index.ts:26`](../packages/subagent/subagent-spawn/src/index.ts)
+Source: [`packages/subagent/subagent-spawn/src/index.ts:36`](../packages/subagent/subagent-spawn/src/index.ts)
 
 ## `@deepseek-ai/dsh-system-prompt`
 
@@ -565,10 +574,33 @@ export interface Config {
    * deployment opens with the harness identity alone.
    */
   persona?: string
+  /**
+   * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
+   * tools take their listed position, and tools absent from the list are
+   * inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
+   * lexicographic name order. A configured list must contain the rest entry
+   * exactly once, no duplicate names, and no name without a registered tool —
+   * a misconfigured order blocks work instead of silently reaching a model
+   * request: shape violations throw at load, and an unregistered name rejects
+   * every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
+   * not be a collected tool name; such a provider output also rejects the
+   * assembly. The single assembly-time validation rejects either failure
+   * before any model request — the earliest moment the registered tool set
+   * exists to check against, since tool plugins register after this service
+   * constructs. When omitted, tools are ordered lexicographically by name.
+   * Applied to the tools
+   * {@link SystemPrompt.assemble} collects, BEFORE the
+   * `system-prompt/assemble` waterfall — like the sections' `order` sort, it
+   * canonicalizes what the registry contributed (registration order is a
+   * plugin-load artifact); a waterfall listener that mutates the tool list
+   * owns the determinism of what it emits. Rationale (and why not per-plugin
+   * weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
+   */
+  toolOrder?: string[]
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:114`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts)
 
 ## `@deepseek-ai/dsh-tool-fs`
 

+ 3 - 3
docs/cordis-catalog/events.md

@@ -323,7 +323,7 @@ A tool was registered or unregistered (the available tool set changed).
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -335,7 +335,7 @@ 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:82`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:92`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -347,7 +347,7 @@ 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:66`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts)
 
 ## Inherited events (cordis core + loader/hmr/timer)
 

+ 3 - 3
docs/cordis-catalog/services.md

@@ -187,10 +187,10 @@ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections,
 section(section: PromptSection): () => void
 tools(provider: () => ToolSchema[]): () => void
 variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
-assemble(context: AssembleContext = {}): Promise<PromptAssembly>
+async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:203`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts)
 
 ## `ctx.tools` — `ToolRegistry`
 
@@ -205,7 +205,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
 
 Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:278`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.web` — `WebService`
 

+ 1 - 1
docs/core-data-structures/core.md

@@ -193,7 +193,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
 
 ### The request envelope: `LlmCallConfig` and the logged header
 
-Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, assembled tool schemas, and any request-only messages — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/request-advice` waterfall weaves request-only advice around the derived history (recorded as the header's `messagePrefix`/`messageSuffix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
+Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset), and any request-only advice messages — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/request-advice` waterfall weaves request-only advice around the derived history (recorded as the header's `messagePrefix`/`messageSuffix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
 
 On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the request-only `before` advice) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps — → `messageSuffix` (the request-only `after` advice, the last thing the model reads). The advice arrays never enter the derived history; their durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
 

+ 2 - 2
docs/core-data-structures/subagent.md

@@ -20,7 +20,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 three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag.
+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 three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `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)).
 
 ```ts type-equiv
 interface SubagentStartRequest {
@@ -28,7 +28,7 @@ interface SubagentStartRequest {
   parent: Agent
   signal?: AbortSignal
   agentOptions?: AgentOptions
-  outputSchema?: SchemaSpec
+  outputSchema?: StructuredOutputSchema
   maxDepth?: number
   toolFilter?: { allow?: string[]; deny?: string[] }
 }

+ 34 - 0
docs/core-data-structures/tools.md

@@ -138,6 +138,40 @@ type PostToolDecision =
 
 Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. 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.
 
+## The structured-output schema subset
+
+The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily.
+
+```ts type-equiv
+type StructuredScalar = string | number | boolean | null
+```
+
+```ts type-equiv
+type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
+```
+
+```ts type-equiv
+interface StructuredSchemaNode {
+  type: StructuredSchemaType
+  properties?: Record<string, StructuredSchemaNode>
+  required?: string[]
+  additionalProperties?: boolean
+  items?: StructuredSchemaNode
+  enum?: StructuredScalar[]
+  const?: StructuredScalar
+  description?: string
+  title?: string
+  default?: unknown
+  examples?: unknown
+}
+```
+
+A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire):
+
+```ts type-equiv
+type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
+```
+
 ## Tool-presentation UI vocabulary
 
 How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:

+ 1 - 1
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 the platform-native `fetch` (Node 24), 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` (`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.

+ 2 - 2
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: 9f63dafdaa533d6340440ab7b7cb2b793ff1d10c
-development.zh.md: cabfc2dc10a63bc15bdea0bf740044e61acd4480
+development.md: bd6f6b561480419abea7a42a44b4078e2c59b1cb
+development.zh.md: 54bf19765d2b4dc419e6b71684dbcfcd28230541

+ 4 - 4
docs/development.md

@@ -2,11 +2,11 @@
 
 English | [中文](development.zh.md)
 
-This guide covers the local setup needed to work on DeepSeek Harness and understand the local hooks, daily checks, and CI gates.
+This onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the RFCs for design rationale and technical trade-offs.
 
 ## Prerequisites
 
-- Node.js 24 or newer. The repo declares `node >=24`; CI runs the matrix on Node 24 and 26.
+- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md).
 - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
 - Git.
 - Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests.
@@ -63,11 +63,11 @@ lefthook is configured in `lefthook.yml` as an early local checkpoint before rev
 
 The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
 
-These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the matrix on Node 24 and 26.
+These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26.
 
 ## CI gates
 
-The keyless GitHub workflow has six jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and the Node 26 compatibility job runs `pnpm run check:node-compat`. The lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
+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 lane schedulers fan out independent gates from `package.json`: constraints, typecheck, 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`.
 

+ 4 - 4
docs/development.zh.md

@@ -2,11 +2,11 @@
 
 [English](development.md) | 中文
 
-本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁
+本文面向参与项目开发的贡献者,帮助你上手本地环境、日常工作流和 CI 流程。相关设计考量和技术取舍参见 RFC,不在这里展开
 
 ## 前置条件
 
-- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵
+- Node.js 支持 22.19+ 和 24+。CI 覆盖 22.19、24、26;见 [Node engine floor RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)
 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。
 - Git。
 - 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。
@@ -63,11 +63,11 @@ lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点
 
 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。
 
-这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 24 和 26 上跑矩阵。
+这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上跑兼容性矩阵。
 
 ## CI 门禁
 
-keyless GitHub 工作流有个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,Node 26 兼容性 job 运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
+keyless GitHub 工作流有个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,三个兼容性 job 在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
 
 `pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。
 

+ 3 - 3
docs/event-producer-consumer.md

@@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `subagent/start` | `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) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../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:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:87`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:66`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 
 Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.

+ 3 - 1
docs/module-graph.md

@@ -171,6 +171,8 @@ flowchart TD
   pkg_subagent_inprocess --> pkg_llm
   pkg_subagent_inprocess --> pkg_session
   pkg_subagent_inprocess --> pkg_subagent
+  pkg_subagent_inprocess --> pkg_system_prompt
+  pkg_subagent_inprocess --> pkg_tools
   pkg_tool_subagent --> pkg_agent
   pkg_tool_subagent --> pkg_llm
   pkg_tool_subagent --> pkg_subagent
@@ -241,7 +243,7 @@ flowchart TD
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
 | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
-| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
+| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |

+ 2 - 0
docs/rfc/INDEX.md

@@ -60,6 +60,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
 | [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
 | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
+| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
 
 ### Simplification
 
@@ -145,6 +146,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 |
 | [Export-surface JSDoc gate](implemented/process/2026-07-06-export-surface-jsdoc-gate.md) | 2026-07-06 |
 | [Generated plugin config catalog](implemented/process/2026-07-06-generated-config-catalog.md) | 2026-07-06 |
+| [Raise the Node LTS engine floor to 22.19](implemented/process/2026-07-06-node-engine-floor.md) | 2026-07-06 |
 | [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 |
 | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 |
 

+ 1 - 1
docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md

@@ -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 the platform-native `fetch` (Node 24), 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 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.
 
 `@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.
 

+ 49 - 0
docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md

@@ -0,0 +1,49 @@
+# RFC: Explicit model-facing tool order
+
+Status: implemented
+
+## Problem
+
+The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue.
+
+## Decision
+
+The system-prompt assembly owns the canonical model-facing tool order, exactly where it already owns section order. `toolOrder?: string[]` on `dsh-system-prompt` is the optional explicit policy:
+
+- A listed tool that is registered takes its listed position.
+- A listed name with no registered tool is a configuration error. Shape errors (rest entry missing or duplicate names) fail from the service constructor; an unregistered name rejects every `assemble()` — the earliest moment the registered tool set exists to check against (tool plugins register after the service constructs), and the only universal one (registrations can change at any time; cordis has no "all plugins loaded" event). Under the shipped loop the first turn fails before any model request — see the consequences below for the exact blast radius.
+- A registered tool absent from the list is inserted at the `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among the other unlisted tools.
+- No collected tool may use `TOOL_ORDER_REST` as its `ToolSchema.name`; the assembly rejects that reserved name before ordering.
+- The list must contain the rest entry exactly once and no duplicate names.
+- When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration.
+
+The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. Everything downstream inherits the order untouched: the waterfall, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check all see one deterministic list, with no new loop change.
+
+Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay).
+
+Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-agent`, `dsh-acp-agent`) accept the key and forward it through `dsh-agent-core` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`.
+
+## Alternatives considered
+
+- **Registration order (the status quo)** — a concurrent-import race, host-dependent (the CI flake above), invisible in review.
+- **A linearization of the plugin dependency graph** — the relation is partial and independent tool plugins are incomparable; the flake happened with the partial order fully satisfied.
+- **Per-plugin `weight` on each tool contribution** — scatters the order across plugins yet still needs a global numbering convention nobody owns (the section `order` bands show that coordination cost being paid by hand).
+- **Sorting in `ToolRegistry.schemas()` (the registry layer)** — equally deterministic, but the registry is a membership store consumed by more than the assembly; ordering is a prompt-composition concern, and the assembly already owns the composition policy for sections.
+- **A `LlmService` config + `orderTools()` method the loop calls before logging the header** — works, but adds a public service method and a loop edit solely to apply a policy at a distance; every future request composer must remember the call. Canonicalizing where the list is born makes an unordered list unrepresentable, with zero new surface.
+- **Normalizing inside `llm.stream()`** — runs after the header event is logged (the flake survives) and rebuilds the deep-frozen envelope, silently disarming the reconstruction invariant.
+- **An exhaustive list (no rest entry)** — every newly loaded tool plugin would break boot; the mandatory rest entry keeps unlisted tools deterministic and their position explicit.
+- **A boot-time validation pass (a `SystemPrompt.assertToolOrderSatisfied()` called by `dsh-app-boot` after `loader.await()`)** — would turn the misconfiguration into a startup death instead of a first-turn failure, but costs a public service method plus a structural coupling from the generic boot glue to one service, and cannot replace the assembly-time check anyway (embedded callers never run app boot; registrations change after boot). No existing event can host the check either: cordis v4 has no ready-like event, `loader/entry-init`/`internal/status` fire mid-load (racy against tool registration, the very entropy this RFC kills), and the agent lifecycle events are no earlier than the assembly. One enforcement point at `assemble()` was judged worth the later failure moment.
+
+## Consequences
+
+- Every assembly — and therefore every `request/header` event and model request — has a deterministic tool order on every host; the CI-vs-local golden flip is structurally gone. The default order is lexicographic, no longer registration order.
+- `PromptAssembly.tools` itself is canonical, so every assembly consumer (the loop, waterfall listeners, any future prompt inspector) sees the model-facing order; provider registration order is observable nowhere downstream of the registry.
+- The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design.
+- A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve.
+- The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched.
+- A misspelled or unloaded tool name in `toolOrder` fails the turn at prompt assembly, not the boot: the loop assembles inside the turn (after `turn/start`, before `step/start`), so the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason carrying the message, `agent/error` mirrors it, no step opens, no `request/header` is logged, no request reaches the adapter, and the agent returns to idle. Every turn fails identically until the config is fixed; the process itself stays up (matching the repo rule that explicit config references must not be silently ignored — the enforcement point is the assembly because no earlier universal moment exists).
+- A tool provider that returns the reserved rest-entry name has the same prompt-assembly failure shape as an unknown listed name. This keeps the sentinel from becoming an ambiguous real tool and preserves the "never drops a tool" ordering contract.
+
+## Testing
+
+Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`.

+ 1 - 1
docs/rfc/implemented/process/2026-06-11-quality-gates.md

@@ -14,7 +14,7 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks
 - ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded.
 - Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
 - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
-- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a demo smoke test driving the echo-agent end to end.
+- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus a demo smoke test driving the echo-agent end to end.
 
 ## Consequences
 

+ 37 - 0
docs/rfc/implemented/process/2026-07-06-node-engine-floor.md

@@ -0,0 +1,37 @@
+# RFC: Raise the Node LTS engine floor to 22.19
+
+Status: implemented
+
+## Problem
+
+The Node 22 branch of the root `engines.node` range is a contract for the installed workspace, not only for the runtime APIs the harness source calls directly. It must be no lower than package `engines.node` declarations for dependencies the workspace installs on that branch; otherwise `pnpm install --engine-strict` fails at an advertised LTS version, and non-strict installs run outside a dependency's supported runtime.
+
+## Decision
+
+Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
+
+Two Node features gate the source runtime:
+
+- **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load.
+- **Native TypeScript type-stripping** — the `packages/ui/stdio-agent/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`.
+
+Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use.
+
+`@types/node` remains pinned to the 22.x line (`^22.20.0`) to match the LTS support line: reaching for a Node 23+/24+/25+ API fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only a floor matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing.
+
+## Consequences
+
+- The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
+- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line.
+- The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
+- A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this RFC in the same change.
+
+## Alternatives considered
+
+- **Keep `^22.18.0 || >=24.0.0`.** Rejected: it advertises an LTS version lower than the Pi adapter dependency floor. `@earendil-works/pi-ai@0.79.3` requires `>=22.19.0`.
+- **Downgrade or pin `@earendil-works/pi-ai` to preserve the 22.18 advertised range.** Rejected: the current Pi adapter dependency is part of the intended workspace, and 22.19 is still inside the Node 22 LTS line.
+- **Floor `>=22.13` (the `node:sqlite` boundary) plus `--experimental-strip-types` in the built-bin smoke on 22.13–22.17.** Rejected: it adds a version-conditional test flag for one narrow range and dresses up an experimental-flag dependency as first-class support. The Pi adapter dependency already requires a higher LTS floor.
+- **Open-ended `>=22.19`.** Rejected: it advertises support for Node 23.0–23.5, where `node:sqlite` (until 23.4) or type-stripping (until 23.6) is still flagged.
+- **Include Node 23.6+ (`^22.19.0 || >=23.6.0`).** Rejected: 23.6+ does run both source features unflagged, but Node 23 is end-of-life; advertising a dead release line adds a range term and a CI leg for a runtime no deployment should use.
+- **Matrix `[22, 24, 26]` instead of pinning `22.19`.** Rejected: floating major-version entries drift upward over time and silently stop exercising the declared LTS floor.
+- **Keep `@types/node` ahead of the floor (`^25`).** Rejected: types ahead of the runtime floor let a Node 24/25-only API compile clean and fail only at runtime on 22.x. Pinning `@types/node` to the 22.x line turns that into a compile error everywhere.

+ 1 - 1
docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md

@@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
 
 ### 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 `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's Node 24/26 jobs already own; a second Node version would double real-API calls for no added signal. `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.
+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.
 
 ## Security
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
examples/acp-agent/tests/snapshots/text-turn/session.jsonl


+ 2 - 2
package.json

@@ -5,7 +5,7 @@
   "type": "module",
   "packageManager": "pnpm@11.7.0",
   "engines": {
-    "node": ">=24"
+    "node": "^22.19.0 || >=24.0.0"
   },
   "workspaces": [
     "vendor/*",
@@ -71,7 +71,7 @@
     "@stylistic/eslint-plugin": "^5.10.0",
     "@types/jsdom": "^28.0.3",
     "@types/mdast": "^4.0.4",
-    "@types/node": "^25.3.5",
+    "@types/node": "^22.20.0",
     "@vitest/coverage-v8": "^4.1.8",
     "eslint": "^10.4.1",
     "fast-check": "^4.8.0",

+ 2 - 2
packages/core/agent-core/README.md

@@ -35,11 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
 
 ```ts
 import type { Config } from '@deepseek-ai/dsh-agent-core'
-// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
+// { agents?, persona?, toolOrder? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
 // so validation and defaulting can never drift from the owners'.
 ```
 
-The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
+The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section — and `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
 
 ## Why a code bundle, not a shared YAML include
 

+ 20 - 12
packages/core/agent-core/src/index.ts

@@ -59,17 +59,20 @@ export const name = 'agent-core'
 /**
  * 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` to the system-prompt plugin (the
- * deployment's persona section). Both are optional INPUT here because each
- * owner's schema supplies the default (`[]` / `''`); the schema is the
- * INTERSECTION of the owners' own schemas, so validation and defaulting can
- * never drift from them.
+ * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
+ * plugin (the deployment's persona section and the explicit model-facing tool
+ * order). Every field is optional INPUT here because each owner's schema
+ * supplies the default (`[]` / `''` / absent — lexicographic); the schema is
+ * the INTERSECTION of the owners' own schemas, so validation and defaulting
+ * can never drift from them.
  */
 export interface Config {
   /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
   agents?: AgentLoopConfig['agents']
   /** The deployment persona (see dsh-system-prompt's `Config`). */
   persona?: SystemPromptConfig['persona']
+  /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
+  toolOrder?: SystemPromptConfig['toolOrder']
 }
 
 /** Intersect the owners' schemas so validation + defaulting stay identical. */
@@ -78,11 +81,11 @@ export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as un
 /**
  * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
  * `agent-loop` receives the forwarded `agents` list and `system-prompt` the
- * forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
- * its `inject` until the services it needs exist), but the listing mirrors the
- * dependency layering for readability: the LLM vocabulary and core registries
- * first, then the dev tripwire and the bash tool consumer, then the loop that
- * drives them.
+ * forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
+ * each fiber on its `inject` until the services it needs exist), but the
+ * listing mirrors the dependency layering for readability: the LLM vocabulary
+ * and core registries first, then the dev tripwire and the bash tool consumer,
+ * then the loop that drives them.
  */
 export function apply(ctx: Context, config: Config): void {
   ctx.plugin(Timer)
@@ -91,8 +94,13 @@ export function apply(ctx: Context, config: Config): void {
   // 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.
-  ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
+  // introduce different ones. toolOrder has no owner-supplied default value —
+  // ABSENT means "lexicographic order" — so it is forwarded conditionally
+  // rather than via ??.
+  ctx.plugin(SystemPrompt, {
+    persona: config.persona ?? '',
+    ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
+  })
   ctx.plugin(ToolRegistry)
   ctx.plugin(AgentRegistry)
   ctx.plugin(invariants)

+ 18 - 0
packages/core/agent-core/tests/agent-core.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
+import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
 import * as agentCore from '../src/index.ts'
 import { AgentId } from '@deepseek-ai/dsh-agent'
 
@@ -67,6 +68,23 @@ describe('dsh-agent-core bundle', () => {
     await ctx.fiber.dispose()
   })
 
+  it('forwards toolOrder to the system-prompt assembly', async () => {
+    const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
+    // The bundle's own bash tools pend on the absent `ctx.bash` executor in
+    // this providerless mount, so register two plain tools to order.
+    for (const name of ['alpha', 'zulu']) {
+      ctx.get('tools')!.register({
+        name,
+        description: name,
+        parameters: {},
+        execute: async () => [],
+      })
+    }
+    const assembly = await ctx.get('systemPrompt')!.assemble()
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+    await ctx.fiber.dispose()
+  })
+
   it('re-exports the loop config schema as its own', () => {
     expect(agentCore.Config).toBeDefined()
     expect(agentCore.name).toBe('agent-core')

+ 117 - 0
packages/core/agent-loop/tests/tool-order.spec.ts

@@ -0,0 +1,117 @@
+/**
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
+import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
+import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import { MockAdapter, textResponse } from './mock-adapter.ts'
+
+async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  ctx.llm.registerAdapter(['mock'], adapter)
+  return ctx
+}
+
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+function registerNamed(ctx: Context, name: string) {
+  ctx.tools.register(defineTool({
+    name,
+    description: `the ${name} tool`,
+    parameters: {},
+    async execute() {
+      return [{ type: 'text', text: name }]
+    },
+  }))
+}
+
+/** Run one text-only turn and return the harness context + agent. */
+async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
+  const adapter = new MockAdapter([textResponse('done')])
+  const ctx = await harness(adapter, toolOrder)
+  for (const name of registrationOrder) registerNamed(ctx, name)
+  const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+  agent.send([{ type: 'text', text: 'go' }])
+  await waitForIdle(ctx, agent)
+  return { ctx, agent, adapter }
+}
+
+describe('loop-level canonical tool order', () => {
+  it('logs the request/header with tools in canonical order, not registration order', async () => {
+    const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    // The dispatched request is built FROM the logged header (whose tools the
+    // assembly already canonicalized) and reaches the adapter deep-frozen —
+    // the marker the reconstruction invariant keys on.
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+    expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
+  })
+
+  it('produces the same header order for any registration order', async () => {
+    const first = await runTurn(['alpha', 'mike', 'zulu'])
+    const second = await runTurn(['zulu', 'mike', 'alpha'])
+    const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
+    expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(names(second)).toEqual(names(first))
+  })
+
+  it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
+    const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+  })
+
+  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.
+    const adapter = new MockAdapter([textResponse('never sent')])
+    const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
+    registerNamed(ctx, 'alpha')
+    const errors: Error[] = []
+    ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(0)
+    expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
+    expect(foldRequestHeader(agent.session.events)).toBeUndefined()
+    const end = agent.session.events.find(e => e.type === 'turn/end')
+    expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
+    // The turn is balanced (turn/start → turn/end) with no step events inside.
+    expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
+  })
+})

+ 3 - 2
packages/core/system-prompt/README.md

@@ -7,15 +7,16 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
 | Key | Default | Meaning |
 |---|---|---|
 | `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
+| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
 
 ## Service: `SystemPrompt` (ctx key: `systemPrompt`)
 
 ### Public API
 
 - `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
-- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
+- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
-- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
+- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
 
 ### Events
 

+ 124 - 13
packages/core/system-prompt/src/index.ts

@@ -88,7 +88,8 @@ export interface AssembledSection {
  *
  * 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.
+ * `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
@@ -110,6 +111,70 @@ 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.
+ */
+export const TOOL_ORDER_REST = '<unlisted-tools>'
+
+/**
+ * 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.
+ */
+function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
+  if (toolOrder === undefined) return undefined
+  const seen = new Set<string>()
+  for (const name of toolOrder) {
+    if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
+    seen.add(name)
+  }
+  if (!seen.has(TOOL_ORDER_REST)) {
+    throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
+  }
+  return toolOrder
+}
+
+/**
+ * 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 with no collected tool throws — misconfiguration fails loud, and this
+ * 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. Never drops a tool, and both sorts are stable, so tools sharing a
+ * name keep their collection order.
+ */
+function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
+  const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
+  if (reserved !== undefined) {
+    throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
+  }
+  if (toolOrder === undefined) return tools.sort(compareToolNames)
+  const registered = new Set(tools.map(tool => tool.name))
+  const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
+  if (unknown.length > 0) {
+    throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
+  }
+  const listed = new Set(toolOrder)
+  const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
+  return toolOrder.flatMap(name =>
+    name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
+}
+
+/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
+function compareToolNames(a: ToolSchema, b: ToolSchema): number {
+  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
+}
+
 /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
 export interface Config {
   /**
@@ -125,6 +190,29 @@ export interface Config {
    * deployment opens with the harness identity alone.
    */
   persona?: string
+  /**
+   * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
+   * tools take their listed position, and tools absent from the list are
+   * inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
+   * lexicographic name order. A configured list must contain the rest entry
+   * exactly once, no duplicate names, and no name without a registered tool —
+   * a misconfigured order blocks work instead of silently reaching a model
+   * request: shape violations throw at load, and an unregistered name rejects
+   * every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
+   * not be a collected tool name; such a provider output also rejects the
+   * assembly. The single assembly-time validation rejects either failure
+   * before any model request — the earliest moment the registered tool set
+   * exists to check against, since tool plugins register after this service
+   * constructs. When omitted, tools are ordered lexicographically by name.
+   * Applied to the tools
+   * {@link SystemPrompt.assemble} collects, BEFORE the
+   * `system-prompt/assemble` waterfall — like the sections' `order` sort, it
+   * canonicalizes what the registry contributed (registration order is a
+   * plugin-load artifact); a waterfall listener that mutates the tool list
+   * owns the determinism of what it emits. Rationale (and why not per-plugin
+   * weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
+   */
+  toolOrder?: string[]
 }
 
 /**
@@ -203,14 +291,23 @@ function interpolate(section: AssembledSection, variables: Record<string, string
 export class SystemPrompt extends Service {
   static Config: z<Config> = 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.
+    toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   })
 
   private sections: PromptSection[] = []
   private toolProviders: (() => ToolSchema[])[] = []
   private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
+  private readonly toolOrder: string[] | undefined
 
   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
@@ -266,7 +363,10 @@ export class SystemPrompt extends Service {
   /**
    * Contribute a tool-schema provider that is evaluated at each assembly
    * call (so it can reflect the live registry state). The provider is
-   * removed when the calling fiber is disposed. Emits `system-prompt/change`.
+   * 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.
    */
@@ -323,19 +423,28 @@ export class SystemPrompt extends Service {
 
   /**
    * Assemble the current prompt for one caller: section texts are resolved
-   * against `context` and sorted by order, tools collected from all
-   * providers, and every registered variable resolved against `context` into
-   * `assembly.variables`. Tool schemas are deep-cloned because adapters and
-   * request waterfalls may mutate schema objects. Runs through the
-   * `system-prompt/assemble` waterfall, giving listeners the opportunity to
-   * mutate or replace the assembly before it reaches the model. Await the
-   * result before reading the assembly values — waterfall listeners may be
-   * async. Interpolation happens later, in {@link renderPrompt}.
+   * against `context` and sorted by order, tools collected from all providers
+   * 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 no provider contributed rejects the assembly), and every
+   * registered variable resolved against `context` into `assembly.variables`.
+   * Tool schemas are deep-cloned because adapters and request waterfalls may
+   * mutate schema objects. Runs through the `system-prompt/assemble`
+   * waterfall, giving listeners the opportunity to mutate or replace the
+   * assembly before it reaches the model — like the sections' `order` sort,
+   * tool canonicalization happens on the initial assembly, and a listener
+   * owns the determinism of whatever it emits. 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(context: AssembleContext = {}): Promise<PromptAssembly> {
+  // async so the misconfigured-toolOrder throw in orderTools surfaces as a
+  // rejection: a Promise-returning method must not throw synchronously
+  // (`assemble().catch(...)` would miss it).
+  async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
     const variables: Record<string, string | undefined> = {}
     for (const [name, provider] of this.variableProviders) {
       variables[name] = provider(context)
@@ -348,8 +457,10 @@ export class SystemPrompt extends Service {
           text: typeof section.text === 'function' ? section.text(context) : section.text,
         }))
         .sort((a, b) => a.order - b.order),
-      tools: this.toolProviders.flatMap(provider =>
-        provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
+      tools: orderTools(
+        this.toolProviders.flatMap(provider =>
+          provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
+        this.toolOrder),
       variables,
     }
     return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))

+ 115 - 0
packages/core/system-prompt/tests/tool-order.spec.ts

@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
+import type { ToolSchema } from '@deepseek-ai/dsh-llm'
+
+function tool(name: string, description = name): ToolSchema {
+  return { name, description, parameters: { type: 'object', properties: {} } }
+}
+
+async function mount(config: { persona?: string; toolOrder?: string[] } = {}): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt, config)
+  return ctx
+}
+
+function names(assembly: PromptAssembly): string[] {
+  return assembly.tools.map(t => t.name)
+}
+
+describe('SystemPrompt tool order', () => {
+  // The ONE place the public constant's value is pinned; everything else
+  // (tests and deployment configs alike) references TOOL_ORDER_REST.
+  it('exports the rest entry as "<unlisted-tools>"', () => {
+    expect(TOOL_ORDER_REST).toBe('<unlisted-tools>')
+  })
+
+  it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
+    const ctx = await mount()
+    ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
+    ctx.systemPrompt.tools(() => [tool('bravo')])
+    expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
+  })
+
+  it('assembles the same order regardless of provider registration order', async () => {
+    const forward = await mount()
+    forward.systemPrompt.tools(() => [tool('alpha')])
+    forward.systemPrompt.tools(() => [tool('zulu')])
+    const backward = await mount()
+    backward.systemPrompt.tools(() => [tool('zulu')])
+    backward.systemPrompt.tools(() => [tool('alpha')])
+    expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
+    expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
+  })
+
+  it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
+    const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
+    ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
+    expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
+  })
+
+  it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
+    const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
+    ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
+    await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
+      'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
+  })
+
+  it('names the single unregistered tool when no tools are registered at all', async () => {
+    const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
+    await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
+      'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
+  })
+
+  it.each([
+    ['without an explicit toolOrder', undefined],
+    ['with only the rest entry configured', [TOOL_ORDER_REST]],
+  ])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
+    const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
+    ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
+    await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
+      `tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
+  })
+
+  it('keeps collection order between tools that share a name (stable sort)', async () => {
+    const ctx = await mount()
+    ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
+    const assembly = await ctx.systemPrompt.assemble()
+    expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
+  })
+
+  it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
+    const ctx = await mount()
+    ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
+    let seen: string[] | undefined
+    ctx.on('system-prompt/assemble', function (assembly, _context, next) {
+      seen = assembly.tools.map(t => t.name)
+      // A listener-appended tool is NOT re-sorted — same contract as sections:
+      // canonicalization applies to what the registry contributed, and a
+      // listener owns the determinism of what it emits.
+      assembly.tools.push(tool('aardvark'))
+      return next()
+    })
+    const assembly = await ctx.systemPrompt.assemble()
+    expect(seen).toEqual(['alpha', 'zulu'])
+    expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
+  })
+
+  it.each([
+    ['an empty list', []],
+    ['a list without the rest entry', ['bash', 'todo_write']],
+  ])('rejects %s at load (the rest entry is required)', async (_case, toolOrder) => {
+    await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow(`must contain the "${TOOL_ORDER_REST}" rest entry`)
+  })
+
+  it.each([
+    ['a duplicate tool name', ['bash', 'bash', TOOL_ORDER_REST]],
+    ['a duplicate rest entry', [TOOL_ORDER_REST, 'bash', TOOL_ORDER_REST]],
+  ])('rejects %s at load', async (_case, toolOrder) => {
+    await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow('more than once')
+  })
+
+  it('throws from direct construction too', () => {
+    expect(() => new SystemPrompt(new Context(), { toolOrder: ['bash'] })).toThrow('rest entry')
+  })
+})

+ 6 - 0
packages/core/tools/README.md

@@ -71,6 +71,12 @@ A `defineTool` tool also **validates the model-generated arguments against its `
 
 See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
 
+### 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).
+
 ### 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`):

+ 10 - 0
packages/core/tools/src/index.ts

@@ -28,6 +28,16 @@ export {
   type JsonSchemaObject,
 } from './schema.ts'
 
+export {
+  assertSupportedOutputSchema,
+  validateStructuredValue,
+  OutputSchemaError,
+  type StructuredOutputSchema,
+  type StructuredSchemaNode,
+  type StructuredSchemaType,
+  type StructuredScalar,
+} from './json-schema.ts'
+
 // The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
 // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
 // stays the single public surface for consumers (producers + the ACP bridge).

+ 345 - 0
packages/core/tools/src/json-schema.ts

@@ -0,0 +1,345 @@
+/**
+ * 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).
+ *
+ * @module dsh-tools/json-schema
+ */
+
+import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
+
+/** The scalar values `enum`/`const` may carry (finite numbers only). */
+export type StructuredScalar = string | number | boolean | null
+
+/** The `type` keywords the subset accepts. */
+export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
+
+/**
+ * One node of the structured-output schema subset. Recursive via `properties`
+ * and `items`; see the module doc for the exact keyword semantics.
+ */
+export interface StructuredSchemaNode {
+  type: StructuredSchemaType
+  /** Nested property schemas (`type: 'object'` only). */
+  properties?: Record<string, StructuredSchemaNode>
+  /** Required property names; each must appear in `properties`. */
+  required?: string[]
+  /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
+  additionalProperties?: boolean
+  /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
+  items?: StructuredSchemaNode
+  /** Allowed values (scalar types only). */
+  enum?: StructuredScalar[]
+  /** The single allowed value (scalar types only). */
+  const?: StructuredScalar
+  /** Annotation, ignored for validation. */
+  description?: string
+  /** Annotation, ignored for validation. */
+  title?: string
+  /** Annotation, ignored for validation (must still be JSON data). */
+  default?: unknown
+  /** Annotation, ignored for validation (must still be JSON data). */
+  examples?: unknown
+}
+
+/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
+export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
+
+/**
+ * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
+ * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
+ * so seam code and tool results can route on it; `violations` lists every
+ * offending path, not just the first.
+ */
+export class OutputSchemaError extends HarnessError {
+  /** The individual violation messages, in walk order. */
+  readonly violations: string[]
+
+  constructor(violations: string[]) {
+    super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
+    this.name = 'OutputSchemaError'
+    this.violations = violations
+  }
+}
+
+/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
+const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
+const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
+
+const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
+
+/**
+ * Whether a value is a PLAIN JSON object — non-null, non-array, and with a
+ * prototype chain of at most one link (`null`-proto, or any realm's
+ * `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
+ * purpose: a schema materialized in another realm carries THAT realm's
+ * `Object.prototype`, which an identity check would wrongly reject. Exotic
+ * hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
+ * they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
+ * failing loud.
+ */
+function isObjectLike(value: unknown): value is Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const proto: unknown = Object.getPrototypeOf(value)
+  return proto === null || Object.getPrototypeOf(proto) === null
+}
+
+/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
+function isStructuredScalar(value: unknown): value is StructuredScalar {
+  return value === null || typeof value === 'string' || typeof value === 'boolean'
+    || (typeof value === 'number' && Number.isFinite(value))
+}
+
+/**
+ * Whether a value is JSON data (annotation payloads only): scalars, arrays, and
+ * object-likes of such values. Realm-agnostic on purpose (no prototype check) —
+ * the schema may have been materialized from another realm; structural JSON-ness
+ * is what the wire needs. Cycles are rejected via `seen`.
+ */
+function isJsonData(value: unknown, seen: Set<object>): boolean {
+  if (isStructuredScalar(value)) return true
+  // The scalar check above already returned for null, so `object` here is a real object.
+  if (typeof value !== 'object') return false
+  if (seen.has(value)) return false
+  seen.add(value)
+  try {
+    if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
+    // A non-plain object (Date, Map, class instance) is NOT JSON data even when
+    // it has no enumerable values — it would serialize lossily, not loudly.
+    if (!isObjectLike(value)) return false
+    return Object.values(value).every(entry => isJsonData(entry, seen))
+  } finally {
+    seen.delete(value)
+  }
+}
+
+/** Collect subset violations for one schema node (recursive walk). */
+function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
+  if (!isObjectLike(node)) {
+    violations.push(`${path} must be a schema object`)
+    return
+  }
+  if (seen.has(node)) {
+    violations.push(`${path} is circular`)
+    return
+  }
+  seen.add(node)
+
+  for (const key of Object.keys(node)) {
+    if (CONSTRAINT_KEYWORDS.has(key)) continue
+    if (ANNOTATION_KEYWORDS.has(key)) {
+      if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
+      continue
+    }
+    violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
+  }
+  if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
+    violations.push(`${path}.description must be a string`)
+  }
+  if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
+    violations.push(`${path}.title must be a string`)
+  }
+
+  const type = node.type
+  if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
+    violations.push(Array.isArray(type)
+      ? `${path}.type must be a single type string (type arrays are not supported)`
+      : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
+    seen.delete(node)
+    return
+  }
+  const schemaType = type as StructuredSchemaType
+
+  // Keywords that only make sense on one type are rejected elsewhere — an
+  // `items` on an object (or `properties` on a string) is a schema-author bug
+  // the subset surfaces rather than ignores.
+  const allowedFor: Record<string, StructuredSchemaType[]> = {
+    properties: ['object'],
+    required: ['object'],
+    additionalProperties: ['object'],
+    items: ['array'],
+    enum: ['string', 'number', 'integer', 'boolean', 'null'],
+    const: ['string', 'number', 'integer', 'boolean', 'null'],
+  }
+  for (const [key, types] of Object.entries(allowedFor)) {
+    if (key in node && !types.includes(schemaType)) {
+      violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
+    }
+  }
+
+  switch (schemaType) {
+    case 'object': {
+      const properties = node.properties
+      if (properties !== undefined) {
+        if (!isObjectLike(properties)) {
+          violations.push(`${path}.properties must be an object of schemas`)
+        } else {
+          for (const [key, child] of Object.entries(properties)) {
+            checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
+          }
+        }
+      }
+      const required = node.required
+      if (required !== undefined) {
+        if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
+          violations.push(`${path}.required must be an array of strings`)
+        } else {
+          const declared = isObjectLike(properties) ? properties : {}
+          // The guard above proved every entry is a string.
+          for (const key of required as string[]) {
+            // Own-property check: `in` would let inherited names (`toString`)
+            // satisfy the declared-in-properties contract via the prototype.
+            if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
+          }
+        }
+      }
+      if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
+        violations.push(`${path}.additionalProperties must be a boolean`)
+      }
+      break
+    }
+    case 'array': {
+      if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
+      break
+    }
+    case 'string':
+    case 'number':
+    case 'integer':
+    case 'boolean':
+    case 'null': {
+      const allowed = node.enum
+      if (allowed !== undefined) {
+        if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
+          violations.push(`${path}.enum must be a non-empty array of scalars`)
+        }
+      }
+      if ('const' in node && !isStructuredScalar(node.const)) {
+        violations.push(`${path}.const must be a scalar`)
+      }
+      break
+    }
+    /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
+    default:
+      assertNever(schemaType, 'assertSupportedOutputSchema')
+    /* v8 ignore stop */
+  }
+
+  seen.delete(node)
+}
+
+/**
+ * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
+ * and entirely within the enforced subset. Throws {@link OutputSchemaError}
+ * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
+ * success. Call this at the seam boundary, before any child is created.
+ * @param schema - the caller-supplied schema (unknown until asserted).
+ * @returns nothing — the assertion signature narrows `schema` to
+ * {@link StructuredOutputSchema} in the caller's scope on normal return.
+ */
+export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
+  const violations: string[] = []
+  checkSchemaNode(schema, 'schema', violations, new Set())
+  if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
+    violations.push('schema.type must be "object" (structured output is object-rooted)')
+  }
+  if (violations.length > 0) throw new OutputSchemaError(violations)
+}
+
+/** Collect violations for one value against an (already asserted) schema node. */
+function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
+  switch (node.type) {
+    case 'object': {
+      if (!isObjectLike(value)) return [`"${path}" must be an object`]
+      const violations: string[] = []
+      const properties = node.properties ?? {}
+      // Own-property discipline throughout: JSON carries own enumerable
+      // properties only, so an inherited `toString` must not satisfy
+      // `required`, dodge `additionalProperties: false`, or be validated as if
+      // the value carried it.
+      for (const key of node.required ?? []) {
+        if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
+      }
+      for (const [key, child] of Object.entries(properties)) {
+        if (!Object.hasOwn(value, key) || value[key] === undefined) continue
+        violations.push(...checkValue(child, value[key], `${path}.${key}`))
+      }
+      if (node.additionalProperties === false) {
+        for (const key of Object.keys(value)) {
+          if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
+        }
+      }
+      return violations
+    }
+    case 'array': {
+      if (!Array.isArray(value)) return [`"${path}" must be an array`]
+      if (!node.items) return []
+      const items = node.items
+      return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
+    }
+    case 'string': {
+      if (typeof value !== 'string') return [`"${path}" must be a string`]
+      break
+    }
+    case 'number': {
+      if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
+      break
+    }
+    case 'integer': {
+      if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
+      break
+    }
+    case 'boolean': {
+      if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
+      break
+    }
+    case 'null': {
+      if (value !== null) return [`"${path}" must be null`]
+      break
+    }
+    default:
+      return assertNever(node.type, 'validateStructuredValue')
+  }
+  // Scalar constraint checks, shared by every scalar branch above.
+  if (node.enum && !node.enum.includes(value)) {
+    return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
+  }
+  if ('const' in node && value !== node.const) {
+    return [`"${path}" must be ${JSON.stringify(node.const)}`]
+  }
+  return []
+}
+
+/**
+ * Validate a value against an (already {@link assertSupportedOutputSchema}-
+ * asserted) schema. Returns human-readable, path-qualified violation messages
+ * — empty means valid. Total: never throws, however malformed the value.
+ * @param schema - the asserted schema to check against.
+ * @param value - the candidate value (e.g. parsed tool-call arguments).
+ * @returns every violation found, in walk order (empty = valid).
+ */
+export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
+  return checkValue(schema, value, 'value')
+}

+ 304 - 0
packages/core/tools/tests/json-schema.spec.ts

@@ -0,0 +1,304 @@
+import { describe, expect, it } from 'vitest'
+import {
+  assertSupportedOutputSchema,
+  OutputSchemaError,
+  validateStructuredValue,
+  type StructuredOutputSchema,
+} from '../src/json-schema.ts'
+
+/** Assert-and-narrow helper: the asserted schema, typed. */
+function asserted(schema: unknown): StructuredOutputSchema {
+  assertSupportedOutputSchema(schema)
+  return schema
+}
+
+/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
+function violationsOf(schema: unknown): string[] {
+  try {
+    assertSupportedOutputSchema(schema)
+  } catch (error: unknown) {
+    if (error instanceof OutputSchemaError) return error.violations
+    throw error
+  }
+  throw new Error('expected the schema to be rejected')
+}
+
+describe('assertSupportedOutputSchema', () => {
+  it('accepts a representative subset schema (all supported keywords)', () => {
+    const schema = asserted({
+      type: 'object',
+      description: 'a finding',
+      title: 'Finding',
+      properties: {
+        file: { type: 'string', description: 'path' },
+        line: { type: 'integer' },
+        severity: { type: 'string', enum: ['low', 'high'] },
+        kind: { type: 'string', const: 'bug' },
+        score: { type: 'number' },
+        confirmed: { type: 'boolean' },
+        parent: { type: 'null' },
+        tags: { type: 'array', items: { type: 'string' } },
+        nested: {
+          type: 'object',
+          properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
+          additionalProperties: false,
+        },
+        anything: { type: 'array' },
+      },
+      required: ['file', 'line'],
+      additionalProperties: true,
+    })
+    expect(schema.type).toBe('object')
+  })
+
+  it('rejects a non-object root (scalar/array-rooted schemas)', () => {
+    expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
+    expect(violationsOf({ type: 'array', items: { type: 'string' } }))
+      .toContain('schema.type must be "object" (structured output is object-rooted)')
+  })
+
+  it('rejects non-object schema nodes and missing/unknown type', () => {
+    expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
+    expect(violationsOf(null)).toEqual(['schema must be a schema object'])
+    expect(violationsOf([])).toEqual(['schema must be a schema object'])
+    expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
+    expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
+    expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
+  })
+
+  it('rejects type ARRAYS with a dedicated message', () => {
+    expect(violationsOf({ type: ['string', 'null'] }))
+      .toEqual(['schema.type must be a single type string (type arrays are not supported)'])
+  })
+
+  it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
+    for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
+      const bad = violationsOf({ type: 'object', [keyword]: [] })
+      expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
+    }
+  })
+
+  it('reports EVERY violation, not just the first', () => {
+    const bad = violationsOf({
+      type: 'object',
+      pattern: 'x',
+      properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
+    })
+    expect(bad.length).toBe(3)
+  })
+
+  it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
+    expect(violationsOf({ type: 'object', items: { type: 'string' } }))
+      .toEqual(['schema.items is not supported on type "object"'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
+      .toEqual(['schema.properties.a.properties is not supported on type "string"'])
+    expect(violationsOf({ type: 'object', enum: [1] }))
+      .toEqual(['schema.enum is not supported on type "object"'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
+      .toEqual(['schema.properties.a.const is not supported on type "array"'])
+  })
+
+  it('validates required: must be string[] naming declared properties', () => {
+    expect(violationsOf({ type: 'object', required: 'file' }))
+      .toEqual(['schema.required must be an array of strings'])
+    expect(violationsOf({ type: 'object', required: [1] }))
+      .toEqual(['schema.required must be an array of strings'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
+      .toEqual(['schema.required names "b" which is not in properties'])
+    expect(violationsOf({ type: 'object', required: ['a'] }))
+      .toEqual(['schema.required names "a" which is not in properties'])
+  })
+
+  it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
+    expect(violationsOf({ type: 'object', additionalProperties: {} }))
+      .toEqual(['schema.additionalProperties must be a boolean'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
+      .toEqual(['schema.properties.a.const must be a scalar'])
+  })
+
+  it('rejects non-string description/title and non-JSON annotation payloads', () => {
+    expect(violationsOf({ type: 'object', description: 7 }))
+      .toEqual(['schema.description must be a string'])
+    expect(violationsOf({ type: 'object', title: 7 }))
+      .toEqual(['schema.title must be a string'])
+    expect(violationsOf({ type: 'object', default: () => 1 }))
+      .toEqual(['schema.default annotation must be JSON data'])
+    expect(violationsOf({ type: 'object', examples: [undefined] }))
+      .toEqual(['schema.examples annotation must be JSON data'])
+    expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
+      .toEqual(['schema.examples annotation must be JSON data'])
+    // A cyclic annotation payload is caught by the JSON-data walk.
+    const cyclicAnnotation: Record<string, unknown> = {}
+    cyclicAnnotation.self = cyclicAnnotation
+    expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
+      .toEqual(['schema.default annotation must be JSON data'])
+    // Object/array annotations that ARE JSON data pass.
+    asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
+  })
+
+  it('rejects a circular schema instead of recursing forever', () => {
+    const node: Record<string, unknown> = { type: 'object' }
+    node.properties = { self: node }
+    expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
+  })
+
+  it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
+    const leaf = { type: 'string' }
+    asserted({ type: 'object', properties: { a: leaf, b: leaf } })
+  })
+
+  it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
+    // `'toString' in {}` is true via Object.prototype; the declared-property
+    // contract must be an own-property check.
+    expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
+      .toEqual(['schema.required names "toString" which is not in properties'])
+  })
+
+  it('rejects exotic host objects where the subset expects plain JSON structure', () => {
+    // A Map as `properties` has no own enumerable entries: structurally it
+    // would read as "no properties" and serialize to {} — lossy, not loud.
+    expect(violationsOf({ type: 'object', properties: new Map() }))
+      .toEqual(['schema.properties must be an object of schemas'])
+    // A Date node is not a schema object even though Object.values(date) is [].
+    expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
+      .toEqual(['schema.properties.at must be a schema object'])
+  })
+
+  it('rejects exotic annotation payloads that would serialize lossily', () => {
+    expect(violationsOf({ type: 'object', default: new Date(0) }))
+      .toEqual(['schema.default annotation must be JSON data'])
+    expect(violationsOf({ type: 'object', examples: [new Map()] }))
+      .toEqual(['schema.examples annotation must be JSON data'])
+  })
+})
+
+describe('validateStructuredValue', () => {
+  const schema = asserted({
+    type: 'object',
+    properties: {
+      file: { type: 'string' },
+      line: { type: 'integer' },
+      score: { type: 'number' },
+      confirmed: { type: 'boolean' },
+      parent: { type: 'null' },
+      severity: { type: 'string', enum: ['low', 'high'] },
+      kind: { type: 'string', const: 'bug' },
+      tags: { type: 'array', items: { type: 'string' } },
+      free: { type: 'array' },
+      nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
+    },
+    required: ['file'],
+  })
+
+  it('accepts a fully valid value (empty violations)', () => {
+    expect(validateStructuredValue(schema, {
+      file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
+      severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
+    })).toEqual([])
+  })
+
+  it('reports missing required and wrong root type', () => {
+    expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
+    expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
+    expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
+  })
+
+  it('type-checks every scalar branch with path-qualified messages', () => {
+    expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
+    expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
+    expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
+    expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
+    expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
+    expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
+    expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
+  })
+
+  it('enforces enum membership and const equality', () => {
+    expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
+      .toEqual(['"value.severity" must be one of ["low","high"]'])
+    expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
+      .toEqual(['"value.kind" must be "bug"'])
+  })
+
+  it('checks arrays per index; an items-less array accepts anything', () => {
+    expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
+    expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
+    expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
+  })
+
+  it('recurses into nested objects: required + additionalProperties: false', () => {
+    expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
+      .toEqual(['missing required property "value.nested.x"'])
+    expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
+      .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
+    expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
+      .toEqual(['"value.nested" must be an object'])
+  })
+
+  it('a required key present-but-undefined counts as missing', () => {
+    expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
+  })
+
+  it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
+    // required: ['toString'] must NOT be satisfied by Object.prototype.toString.
+    expect(validateStructuredValue(
+      asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
+      {},
+    )).toEqual(['missing required property "value.toString"'])
+    // additionalProperties: false must flag an OWN `toString` key even though
+    // `'toString' in properties` is true via the prototype.
+    expect(validateStructuredValue(
+      asserted({ type: 'object', additionalProperties: false }),
+      { toString: 1 },
+    )).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
+    // A declared property the value does NOT carry must not be validated
+    // against the value's INHERITED member (constructor is a function on
+    // every plain object's prototype, not a carried property).
+    expect(validateStructuredValue(
+      asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
+      {},
+    )).toEqual([])
+  })
+
+  it('a non-plain object value is not an object in the JSON sense', () => {
+    expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
+      .toEqual(['"value" must be an object'])
+  })
+
+  it('collects multiple violations across branches in one pass', () => {
+    expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
+      'missing required property "value.file"',
+      '"value.line" must be an integer',
+      '"value.severity" must be one of ["low","high"]',
+    ])
+  })
+
+  it('null-typed const/enum work through the scalar path', () => {
+    const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
+    expect(validateStructuredValue(nullish, { a: null })).toEqual([])
+  })
+
+  it('rejects a non-object properties value in the schema walk', () => {
+    expect(violationsOf({ type: 'object', properties: [] }))
+      .toEqual(['schema.properties must be an object of schemas'])
+  })
+
+  it('an object schema without properties/required only type-checks its value', () => {
+    const bare = asserted({ type: 'object' })
+    expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
+    expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
+  })
+
+  it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
+    const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
+    expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
+  })
+})

+ 1 - 1
packages/session-persistence/session-persistence-sqlite/README.md

@@ -8,7 +8,7 @@ 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 targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. 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 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).
 
 ## Contract semantics over rows
 

+ 1 - 1
packages/subagent/subagent-fork/README.md

@@ -12,7 +12,7 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade
 
 ## Capabilities
 
-`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's).
+`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's).
 
 ## Config
 

+ 8 - 3
packages/subagent/subagent-fork/src/index.ts

@@ -28,6 +28,10 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
 import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
 
 export const name = 'subagent-fork'
+// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
+// per-run structured runtime gates its capture-tool registration on `tools`
+// itself, so this backend's apply timing (and the delegation tool's position
+// in the model-visible tool list) is unchanged by structured output.
 export const inject = ['subagents', 'agents']
 
 /** Config: the registry name to register the provider under. */
@@ -59,11 +63,12 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
 }
 
 /**
- * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this
- * cut (the service rejects a request needing either before `start` runs).
+ * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
+ * in-process structured runtime); NOT `toolFilter` this cut (the service
+ * rejects a request needing it before `start` runs).
  */
 class ForkProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
   // Context contract: a forked child IS seeded with the parent's completed-turn prefix.
   readonly inheritsParentContext = true
 

+ 24 - 3
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -9,9 +9,10 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import SubagentService from '@deepseek-ai/dsh-subagent'
-import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import * as fork from '../src/index.ts'
+import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
 import { completedTurnPrefix } from '../src/index.ts'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -141,6 +142,26 @@ describe('dsh-subagent-fork', () => {
     await run.dispose()
   })
 
+  it('captures structured output through the shipped plugin (seeded child, driver runtime)', async () => {
+    const { ctx, parent } = await setup([
+      textResponse('parent turn'),
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
+    ])
+    parent.send([{ type: 'text', text: 'warm up' }])
+    await parent.whenIdle()
+    const run = ctx.subagents.start('fork', {
+      prompt: [{ type: 'text', text: 'report structured' }],
+      parent,
+      outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 9 })
+    // Run-scoped runtime: nothing stays registered after the settle.
+    expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    await run.dispose()
+  })
+
   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,
@@ -161,9 +182,9 @@ describe('dsh-subagent-fork', () => {
     await run.dispose()
   })
 
-  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
     const { ctx } = await setup([])
-    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {

+ 17 - 4
packages/subagent/subagent-inprocess/README.md

@@ -8,10 +8,10 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r
 
 Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
 
-1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
-2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
-3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts);
-4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`.
+1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) and then snapshotted with `structuredClone` before any child exists — assertion first so a hostile value fails as `OutputSchemaError` (never a raw clone error), the snapshot so a post-`start()` caller mutation cannot drift the enforced schema;
+2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the deployment persona needs no inheritance — it is a context-wide prompt section);
+3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
+4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
 
 `dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
 
@@ -19,6 +19,19 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
 
 `{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
 
+### Structured output (package-internal runtime)
+
+The mechanism behind `outputSchema` for in-process children — acquired per structured RUN inside `startInProcessRun` (nothing is registered on a context that never runs a structured child; only the model-facing constants `STRUCTURED_OUTPUT_TOOL`/`STRUCTURED_OUTPUT_INSTRUCTION` are exported). One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus four listeners:
+
+- a `system-prompt/assemble` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-assembly enforcement**: the assembly the loop renders never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction as a trailing prompt section (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). The loop logs the rendered assembly as the step's `request/header`, so the injection is reconstructable log state, never a wire-only mutation. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child (FIXME in the module doc: per-agent/per-session scoping would dissolve this); cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement assembly.
+- a `tools/post-execute` listener (`prepend: true` = outermost, so `await next()` yields the composed final decision) that COMMITS the capture: the tool body only stages the validated value, and it becomes the run's result only when the final decision accepts the call — a downstream block (a PostToolUse hook) turns the logged result into `isError`, and the run must not report `structured` success for a call the model and session log saw fail.
+- a `tools/pre-execute` deny for any call arriving after the agent's capture — terminal means terminal WITHIN the step: a response listing `structured_output` before further tool calls cannot run side effects after the final answer was accepted.
+- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
+
+The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call stages the value for the post-execute commit.
+
+Lifetime is refcounted by live structured runs: each acquires at start and releases at settle, so the registrations exist exactly while at least one structured child is live, a backend hot-reload mid-run cannot unregister the capture tool under a live child, and the last settle disposes everything. `release()` is idempotent per acquisition.
+
 ### `depthOf(agent): number`
 
 Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0).

+ 2 - 0
packages/subagent/subagent-inprocess/package.json

@@ -26,6 +26,8 @@
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {

+ 69 - 5
packages/subagent/subagent-inprocess/src/index.ts

@@ -18,7 +18,20 @@ import type { Context } from 'cordis'
 import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
 import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
 import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
+import {
+  acquireStructuredRuntime,
+  type StructuredAcquisition,
+} from './structured.ts'
+
+// The runtime itself (acquire/attach/release) is package-internal: runs
+// acquire it inside startInProcessRun, and no other package drives it. Only
+// the model-facing vocabulary is public.
+export {
+  STRUCTURED_OUTPUT_TOOL,
+  STRUCTURED_OUTPUT_INSTRUCTION,
+} from './structured.ts'
 
 declare module '@deepseek-ai/dsh-agent' {
   interface AgentOptions {
@@ -109,6 +122,18 @@ export function startInProcessRun(
   if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
     throw new SubagentDepthError(childDepth, request.maxDepth)
   }
+  // Assert, then snapshot, the schema subset BEFORE any child exists (the
+  // service has already capability-gated; this rejects a schema outside the
+  // enforced subset loud). Assertion comes FIRST so a hostile value fails as
+  // OutputSchemaError, never as structuredClone's raw DataCloneError — the
+  // asserted subset is plain JSON data, which always clones. The snapshot is
+  // load-bearing: the caller keeps its reference, so attaching the ORIGINAL
+  // would let a post-start() mutation drift the enforced schema away from the
+  // asserted one — the clone (taken synchronously with the assertion, no
+  // interleaving possible) pins assertion, the model-visible parameters, and
+  // validateStructuredValue to one isolation-immutable value.
+  if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
+  const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
 
   const childId = AgentId(randomUUID())
   // The child's OWN events begin after the seed (fork seeds the parent's
@@ -120,13 +145,20 @@ export function startInProcessRun(
   // Inherit the parent's model by default (a child with no model cannot run);
   // an explicit `request.agentOptions.model` overrides it. The persona needs
   // no inheritance: the deployment persona is a context-wide prompt section,
-  // so parent and child render the same one.
+  // so parent and child render the same one. A structured run's
+  // structured_output instruction is NOT prompt state either — the structured
+  // runtime's final-request listener appends it per request (see structured.ts).
   const agentOptions: AgentOptions = {
     ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
     ...request.agentOptions,
     subagentDepth: childDepth,
   }
 
+  // The structured runtime is held for the WHOLE run (acquired before the child
+  // exists, released when the result settles), so a backend hot-reload mid-run
+  // cannot unregister the capture tool out from under this live child.
+  const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
+
   const handle: AgentHandle = ctx.agents.create({
     agentId: childId,
     sessionId: SessionId(randomUUID()),
@@ -141,6 +173,7 @@ export function startInProcessRun(
     agentOptions,
   })
   const child = handle.agent
+  if (structured && schema !== undefined) structured.attach(child, schema)
 
   // Bridge the request's abort signal to the child (the consumer also bridges
   // its own exec.signal, but a backend-level bridge keeps the contract local).
@@ -149,6 +182,10 @@ export function startInProcessRun(
   // `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
   // rather than falling through to the no-turn `error` mapping.
   let cancelled = false
+  // An accessor, not an inline read: `cancelled` mutates from closures (the
+  // abort listener, run.cancel), which control-flow narrowing cannot see — an
+  // inline read at the result mapping would narrow to the initializer.
+  const isCancelled = (): boolean => cancelled
   const requestCancel = (reason: string): void => {
     cancelled = true
     child.cancel(reason)
@@ -165,9 +202,16 @@ export function startInProcessRun(
       if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
       child.send(request.prompt)
       await child.whenIdle()
-      return readResult(child, seedLength, cancelled)
+      // Deliberately NO re-prompt when a structured child finishes cleanly
+      // without calling structured_output: readResult maps that to `error` —
+      // the shortfall goes to the parent instead of buying extra model turns.
+      return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
     } finally {
       request.signal?.removeEventListener('abort', onAbort)
+      if (structured) {
+        structured.detach(child)
+        structured.release()
+      }
     }
   })()
 
@@ -195,12 +239,32 @@ export function startInProcessRun(
  * logged (a cancel landed in the pre-turn window, before any turn ran), the
  * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
  * the generic no-turn `error`.
+ *
+ * A structured run (`structured` present) additionally reports the captured
+ * value on {@link SubagentResult.structured}. A structured child that finished
+ * CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean
+ * finish without the demanded structured result is a failure, not a success
+ * with a missing field; a non-`completed` reason keeps its own honest mapping.
  */
-function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult {
+function readResult(
+  child: Agent,
+  seedLength: number,
+  cancelled: boolean,
+  structured?: { captured?: { value: unknown } | undefined },
+): SubagentResult {
   const own = child.session.events.slice(seedLength)
   const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
   const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
   const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
-  if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' }
-  return { output, stopReason: toStopReason(lastEnd?.data.reason) }
+  const stopReason: SubagentStopReason = lastEnd === undefined && cancelled
+    ? 'aborted'
+    : toStopReason(lastEnd?.data.reason)
+  if (structured) {
+    if (structured.captured) return { output, structured: structured.captured.value, stopReason }
+    // No capture on a cleanly-completed turn: an ERROR when the run was left
+    // to finish (the nudges ran out), but ABORTED when a cancel is why the
+    // nudging stopped — the cancel contract outranks the schema shortfall.
+    if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
+  }
+  return { output, stopReason }
 }

+ 312 - 0
packages/subagent/subagent-inprocess/src/structured.ts

@@ -0,0 +1,312 @@
+/**
+ * Structured-output support for the in-process subagent backends: the mechanism
+ * behind `SubagentStartRequest.outputSchema` for children that run as agents on
+ * the same context.
+ *
+ * The model-facing surface is one globally registered `structured_output` tool
+ * whose REGISTERED parameters are a placeholder — the real schema is per run.
+ * Because the tool registry and prompt assembly are context-global while
+ * schemas differ per child (two concurrent structured runs may carry different
+ * schemas), per-agent shaping happens on the `system-prompt/assemble`
+ * waterfall with a `prepend: true` listener that post-processes `await next()`
+ * — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
+ * replaced, the assembly the loop renders never carries `structured_output`
+ * for an agent without a structured run, and for one that has it always
+ * carries the run's OWN schema plus a trailing
+ * {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
+ * tool). The loop logs what the assembly produced as the request header, so
+ * the injection is a reconstructable fact of the session log, never a
+ * wire-only mutation (the reconstructability RFC).
+ * (Cooperative mutate-then-`next()` would not survive a downstream listener
+ * returning a replacement assembly — see the waterfall composition caveat in
+ * docs/architecture.md.)
+ *
+ * FIXME: the whole enforcement dance above exists because the tool registry
+ * and prompt assembly are context-global. If they become per-agent or
+ * per-session scoped, a structured run just registers its own schema'd tool on
+ * the child's scope and this module reduces to the capture tool plus the
+ * turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
+ * else, no global-registration lifetime dance.
+ *
+ * A companion `agent/turn-continuation` listener stops a child's turn once its
+ * output is captured — without it, the loop's default "had tool calls ⇒
+ * continue" buys a wasted extra model step per structured child. It is also
+ * `prepend: true`: the veto must run before any earlier-registered listener
+ * that could short-circuit the chain into a forced continue. A third listener
+ * closes the within-step window the continuation veto cannot: a
+ * `tools/pre-execute` deny for any call arriving after the agent's capture, so
+ * a response that lists `structured_output` before further tool calls cannot
+ * run side effects after the final answer was accepted. A fourth,
+ * `tools/post-execute`, is the capture COMMIT: the tool body only stages the
+ * validated value, and it becomes the run's captured result only when the
+ * final post-execute decision accepts the call — a blocking hook downstream
+ * yields `isError` in the log, and the run must not report success for it.
+ *
+ * Lifetime is refcounted by structured RUNS: each acquires from start to
+ * settle, so the registrations exist exactly while at least one structured
+ * child is live — a plain deployment that never passes `outputSchema` carries
+ * no always-on global state, and a backend hot-reload mid-run cannot
+ * unregister the capture tool out from under a live child (the run holds its
+ * own acquisition). Registrations land on the ROOT context and the refcount
+ * disposes them when the last run settles; the next structured run
+ * re-registers them.
+ *
+ * @module @deepseek-ai/dsh-subagent-inprocess/structured
+ */
+
+import type { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
+import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
+import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
+
+/** The model-facing tool name a structured child must call to finish. */
+export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
+
+/**
+ * The instruction the assembly listener appends to a structured child's
+ * system prompt as a trailing section on every assembly. Per-assembly state,
+ * NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
+ * deployment config on the system-prompt plugin), so the same final-assembly
+ * enforcement that injects the schema'd tool carries the instruction that
+ * demands calling it.
+ */
+export const STRUCTURED_OUTPUT_INSTRUCTION
+  = 'When you have your final answer, you MUST report it by calling the '
+    + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
+    + 'Do not finish with a plain text answer: only the tool call counts as your result.'
+
+/** One structured run's state: the schema to enforce and the captured value, once recorded. */
+interface RunState {
+  readonly schema: StructuredOutputSchema
+  /**
+   * A validated value awaiting the post-execute verdict on ITS OWN call. Set
+   * by the capture tool's body, promoted to {@link RunState.captured} only
+   * when the final `tools/post-execute` decision accepts the call — a
+   * downstream block turns the logged result into `isError`, and a value
+   * committed at body time would let the run report success for a call the
+   * model saw fail.
+   */
+  pending?: { value: unknown }
+  captured?: { value: unknown }
+}
+
+/** The per-root-context runtime: run states plus the shared registrations. */
+interface StructuredRuntime {
+  refs: number
+  readonly states: WeakMap<Agent, RunState>
+  readonly disposers: (() => void)[]
+}
+
+/** One root context ⇒ one runtime (multi-app test isolation). */
+const runtimes = new WeakMap<Context, StructuredRuntime>()
+
+/**
+ * One holder's handle on the shared structured runtime. `release()` is
+ * idempotent per acquisition; the runtime's registrations are disposed when the
+ * LAST holder (backend plugin or live run) releases.
+ */
+export interface StructuredAcquisition {
+  /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
+  attach(agent: Agent, schema: StructuredOutputSchema): void
+  /** The captured value, once the child called the tool with valid arguments. */
+  captured(agent: Agent): { value: unknown } | undefined
+  /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
+  detach(agent: Agent): void
+  /** Drop this holder's reference (idempotent); the last release unregisters everything. */
+  release(): void
+}
+
+/**
+ * Acquire the per-root-context structured runtime, registering the capture tool
+ * and the runtime's listeners on the FIRST acquisition. See the module doc
+ * for the enforcement and lifetime design.
+ * @param ctx - any context of the app; the runtime keys off `ctx.root`.
+ * @returns this holder's handle (attach/captured/detach + idempotent release).
+ */
+export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
+  const root: Context = ctx.root
+  let runtime = runtimes.get(root)
+  if (!runtime) {
+    runtime = { refs: 0, states: new WeakMap(), disposers: [] }
+    runtimes.set(root, runtime)
+    registerRuntime(root, runtime)
+  }
+  runtime.refs += 1
+
+  let released = false
+  return {
+    attach(agent: Agent, schema: StructuredOutputSchema): void {
+      runtime.states.set(agent, { schema })
+    },
+    captured(agent: Agent): { value: unknown } | undefined {
+      return runtime.states.get(agent)?.captured
+    },
+    detach(agent: Agent): void {
+      runtime.states.delete(agent)
+    },
+    release(): void {
+      if (released) return
+      released = true
+      runtime.refs -= 1
+      if (runtime.refs > 0) return
+      runtimes.delete(root)
+      for (const dispose of runtime.disposers.splice(0)) dispose()
+    },
+  }
+}
+
+/** Register the capture tool + the two listeners on the root context (first acquire). */
+function registerRuntime(root: Context, runtime: StructuredRuntime): void {
+  // The registered parameters are a PLACEHOLDER: the request listener below
+  // swaps in the run's real schema per child, and strips the tool entirely for
+  // every agent without a structured run — so this shape is never model-visible.
+  //
+  // Registration does NOT ride on the acquiring backend's plugin-level
+  // `inject`: a backend that waited on `tools` would apply later than it did
+  // before this module existed, shifting when its PROVIDER registers — and the
+  // delegation tool mirrors provider lifecycle, so that shift would reorder
+  // the model-visible tool list of every existing prompt. Instead the capture
+  // tool registers synchronously when `tools` is already live (the common
+  // case), and through a scoped inject fiber when the Loader happens to start
+  // the backend first. Either way the registration lands on root and is
+  // disposed by the runtime's refcount; disposing the fiber also covers the
+  // never-activated case.
+  let disposeTool: (() => void) | undefined
+  const registerCapture = (tools: Context['tools']): void => {
+    disposeTool = tools.register({
+      name: STRUCTURED_OUTPUT_TOOL,
+      description:
+        'Report your final structured result. Call this exactly once, when your answer is complete; '
+        + 'the arguments must match this tool\'s parameter schema exactly.',
+      parameters: { type: 'object', properties: {} },
+      execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
+        const state = exec.agent ? runtime.states.get(exec.agent) : undefined
+        if (!state) {
+          // Reachable only if a non-structured agent somehow calls the tool (the
+          // request listener strips it, so the model never sees it) — fail loud
+          // rather than capture into nowhere.
+          throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
+        }
+        const violations = validateStructuredValue(state.schema, args)
+        // ToolArgsError → isError result with INVALID_ARGS: the model retries
+        // within the same turn, exactly like a schema-validated defineTool call.
+        if (violations.length > 0) throw new ToolArgsError(violations)
+        // Two-phase commit: the body only STAGES the value; the post-execute
+        // listener below promotes it once the final decision accepts the call.
+        state.pending = { value: args }
+        return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
+      },
+    })
+  }
+  const liveTools = root.get('tools')
+  const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
+    registerCapture(childCtx.root.tools)
+  })
+  if (liveTools) registerCapture(liveTools)
+  runtime.disposers.push(() => {
+    disposeTool?.()
+    void toolsFiber?.dispose()
+  })
+
+  // FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
+  // wrapper): post-process whatever the downstream listeners and the registry
+  // produced, so a downstream listener returning a replacement assembly cannot
+  // leak the tool to other agents or erase the child's schema. The loop logs
+  // the rendered assembly as the step's request header, so the swap is
+  // reconstructable log state, never a wire-only mutation.
+  runtime.disposers.push(root.on('system-prompt/assemble', async function (
+    this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
+  ): Promise<PromptAssembly> {
+    const final = await next()
+    const state = context.agent ? runtime.states.get(context.agent) : undefined
+    if (state) {
+      const schemaEntry: ToolSchema = {
+        name: STRUCTURED_OUTPUT_TOOL,
+        description:
+          'Report your final structured result. Call this exactly once, when your answer is complete; '
+          + 'the arguments must match this tool\'s parameter schema exactly.',
+        // ToolSchema.parameters is the wire-level JSON Schema object; the
+        // asserted subset type is structurally exactly that.
+        parameters: state.schema as unknown as Record<string, unknown>,
+      }
+      final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
+      // The demand travels WITH the tool: a trailing section in the
+      // tool-guidance order band, appended after next() so it renders last
+      // (renderPrompt joins in array order).
+      final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
+      return final
+    }
+    // No structured run: strip the placeholder so it is never model-visible.
+    // An empty tools array canonicalizes to an absent header/wire field
+    // (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
+    final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
+    return final
+  }, { prepend: true }))
+
+  // Stop a structured child's turn once its output is captured: the default
+  // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
+  // after every successful capture. `prepend: true` puts the veto OUTERMOST —
+  // an earlier-registered listener that short-circuits the chain (a goal-style
+  // force-continue returning without `next()`) would otherwise decide the turn
+  // before this listener ever ran, and no downstream decision may resurrect a
+  // structured turn that is already finished.
+  runtime.disposers.push(root.on('agent/turn-continuation', function (
+    this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
+  ): Promise<ContinuationDecision> {
+    if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
+    return next()
+  }, { prepend: true }))
+
+  // The capture COMMIT: promote the staged value only when the final
+  // post-execute decision accepts the call. The capture tool's body cannot
+  // decide — `tools/post-execute` runs after it, and a blocking listener (a
+  // PostToolUse hook) turns the logged result into `isError` feedback; a value
+  // committed at body time would make readResult report `structured` success
+  // for a call whose result the model and session log saw fail. `prepend:
+  // true` = outermost at registration time, so `await next()` returns the
+  // COMPOSED downstream decision — the same final verdict the registry maps
+  // onto the result. (A later-registered outer listener that blocks without
+  // delegating skips this commit entirely: the staged value is dropped and the
+  // run errors — failure-safe in the same direction.) The staging slot clears
+  // on every path, including a rejecting downstream listener.
+  runtime.disposers.push(root.on('tools/post-execute', async function (
+    this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
+  ): Promise<PostToolDecision> {
+    const state = exec.agent ? runtime.states.get(exec.agent) : undefined
+    if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next()
+    const pending = state.pending
+    try {
+      const decision = await next()
+      if (decision.kind === 'accept') state.captured = pending
+      return decision
+    } finally {
+      delete state.pending
+    }
+  }, { prepend: true }))
+
+  // Terminal means terminal WITHIN the step, not only at its end: the
+  // turn-continuation veto above runs after every call in the current model
+  // response has executed, so a response that puts `structured_output` before
+  // further tool calls would still perform those side effects after the final
+  // answer was accepted. Deny every later call for a captured agent at the
+  // allow/deny gate — dispatch is skipped and the model sees an `isError`
+  // result naming the contract. Calls that PRECEDE the capture in the same
+  // response ran before `captured` was set and are untouched; a second
+  // `structured_output` is denied like any other call. `prepend: true` for the
+  // same reason as the continuation veto: no earlier-registered allow may
+  // short-circuit past the terminal contract.
+  runtime.disposers.push(root.on('tools/pre-execute', function (
+    this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
+  ): Promise<PreToolDecision> {
+    if (exec.agent && runtime.states.get(exec.agent)?.captured) {
+      return Promise.resolve({
+        kind: 'deny',
+        reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
+      })
+    }
+    return next()
+  }, { prepend: true }))
+}

+ 606 - 0
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -0,0 +1,606 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService, { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { startInProcessRun } from '../src/index.ts'
+import {
+  acquireStructuredRuntime,
+  STRUCTURED_OUTPUT_INSTRUCTION,
+  STRUCTURED_OUTPUT_TOOL,
+} from '../src/structured.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+const SCHEMA: StructuredOutputSchema = {
+  type: 'object',
+  properties: { answer: { type: 'number' }, note: { type: 'string' } },
+  required: ['answer'],
+}
+
+/**
+ * Real loop + scripted mock model + an INLINE spawn-shaped 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.
+ */
+async function setup(script: Script) {
+  const ctx = new Context()
+  const adapter = new MockAdapter(script)
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  const disposeProvider = ctx.subagents.registerProvider({
+    name: 'spawn',
+    capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
+    inheritsParentContext: false,
+    start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
+  })
+  ctx.llm.registerAdapter(['mock'], adapter)
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent, adapter, disposeProvider }
+}
+
+function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
+  return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
+}
+
+/** The tool names of one recorded model request. */
+function toolNames(request: GenerateOptions): string[] {
+  return (request.tools ?? []).map(tool => tool.name)
+}
+
+describe('in-process structured output', () => {
+  it('captures a valid structured_output call and surfaces result.structured', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 42, note: 'done' })
+    await run.dispose()
+  })
+
+  it('stops the turn after a successful capture — no extra model step is spent', async () => {
+    const { ctx, parent, adapter } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+      textResponse('MUST NOT BE CONSUMED'),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    // Default continuation would run a second step after the tool call; the
+    // structured runtime's turn-continuation veto stops the turn instead.
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
+    // One model response carrying structured_output FIRST and a side-effecting
+    // call after it: the continuation veto only fires at step end, so without
+    // the pre-execute deny the trailing call would still run after the final
+    // answer was accepted.
+    const response = [
+      ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
+      { type: 'block-start', index: 1, blockType: 'tool-call' },
+      { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
+      { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
+      { type: 'finish', reason: { kind: 'tool-calls' } },
+    ] as Script[number]
+    const { ctx, parent } = await setup([response])
+    let sideEffectRan = false
+    ctx.tools.register({
+      name: 'side_effect',
+      description: 'probe',
+      parameters: { type: 'object', properties: {} },
+      execute(): Promise<ContentBlock[]> {
+        sideEffectRan = true
+        return Promise.resolve([{ type: 'text', text: 'ran' }])
+      },
+    })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 5 })
+    // The deny skipped dispatch entirely: the probe body never ran.
+    expect(sideEffectRan).toBe(false)
+    await run.dispose()
+  })
+
+  it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
+    const response = [
+      { type: 'block-start', index: 0, blockType: 'tool-call' },
+      { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'side_effect', arguments: '{}' } },
+      ...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
+        'index' in chunk ? { ...chunk, index: 1 } : chunk),
+    ] as Script[number]
+    const { ctx, parent } = await setup([response])
+    let sideEffectRan = false
+    ctx.tools.register({
+      name: 'side_effect',
+      description: 'probe',
+      parameters: { type: 'object', properties: {} },
+      execute(): Promise<ContentBlock[]> {
+        sideEffectRan = true
+        return Promise.resolve([{ type: 'text', text: 'ran' }])
+      },
+    })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    // The call ran BEFORE captured was set: the deny gate only guards the
+    // window after the terminal answer landed.
+    expect(sideEffectRan).toBe(true)
+    expect(result.structured).toEqual({ answer: 6 })
+    await run.dispose()
+  })
+
+  it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => {
+    const mutable: StructuredOutputSchema = {
+      type: 'object',
+      properties: { answer: { type: 'number' } },
+      required: ['answer'],
+      additionalProperties: false,
+    }
+    const pristine = structuredClone(mutable)
+    const { ctx, parent, adapter } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable }))
+    // Mutate the caller's object AFTER start() returned but before the child's
+    // first request assembles: with a live reference this would reach both the
+    // model-visible parameters and validateStructuredValue.
+    ;(mutable.properties as Record<string, unknown>).answer = { type: 'string' }
+    const result = await run.result
+    expect(result.structured).toEqual({ answer: 3 })
+    // The child's request carried the PRISTINE schema, not the mutated one.
+    const childRequest = adapter.requests.at(-1)
+    const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
+    expect(captureTool?.parameters).toEqual(pristine)
+    await run.dispose()
+  })
+
+  it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    // Registered BEFORE the structured runtime exists — without prepend, this
+    // goal-style listener would decide the turn first (returning WITHOUT
+    // calling next()) and the veto would never run.
+    ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
+    const acquisition = acquireStructuredRuntime(ctx)
+    const agent = { id: AgentId('structured-child') } as unknown as Agent
+    acquisition.attach(agent, SCHEMA)
+    const captured = await ctx.tools.execute({
+      callId: 'call-1' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+      agent,
+    })
+    expect(captured.isError).toBeFalsy()
+    const decision = await ctx.waterfall(
+      'agent/turn-continuation', agent, 1,
+      { action: 'continue' },
+      () => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
+    )
+    expect(decision).toEqual({ action: 'stop' })
+    acquisition.detach(agent)
+    acquisition.release()
+  })
+
+  it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
+      toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.structured).toEqual({ answer: 7 })
+    expect(result.stopReason).toBe('completed')
+    // The child's log carries the isError tool/result for the invalid call.
+    const child = ctx.agents.get(run.id)!
+    const results = child.session.events.filter(e => e.type === 'tool/result')
+    expect(results.length).toBe(2)
+    expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
+    await run.dispose()
+  })
+
+  it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
+    const { ctx, parent, adapter } = await setup([
+      textResponse('here is my answer in prose'),
+      textResponse('MUST NOT BE CONSUMED'),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.structured).toBeUndefined()
+    // Exactly one model request and one user message: no nudge turn exists.
+    expect(adapter.requests.length).toBe(1)
+    const child = ctx.agents.get(run.id)!
+    expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
+    await run.dispose()
+  })
+
+  it('an errored child keeps its honest error result (no capture expected)', async () => {
+    // Script exhaustion on the first call → the child turn errors.
+    const { ctx, parent, adapter } = await setup([])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
+    const { ctx, parent } = await setup([textResponse('prose, no capture')])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const child = ctx.agents.get(run.id)!
+    // Cancel synchronously inside the turn's end recording: the cancel
+    // contract outranks the schema shortfall, so the result maps to aborted.
+    ctx.on('session/event', (session, event) => {
+      if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    await run.dispose()
+  })
+
+  it('rejects a schema outside the subset loud, before any child exists', async () => {
+    const { ctx, parent } = await setup([])
+    expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
+      outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
+    }))).toThrow(/unsupported output schema/)
+    expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
+  })
+
+  it('a schema carrying non-JSON values fails as OutputSchemaError, never as a raw clone error', async () => {
+    const { ctx, parent } = await setup([])
+    // Assertion runs BEFORE the defensive structuredClone: a function-valued
+    // annotation must surface as the subset violation it is, not escape as
+    // structuredClone's DataCloneError.
+    expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
+      outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
+    }))).toThrow(/unsupported output schema.*annotation must be JSON data/)
+  })
+
+  it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
+    const { ctx, parent, adapter } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
+      textResponse('continues after the blocked capture'),
+    ])
+    // A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
+    // prepend commit listener stays outermost and composes this verdict).
+    ctx.on('tools/post-execute', (exec, _result, next) => {
+      if (exec.name === STRUCTURED_OUTPUT_TOOL) {
+        return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
+      }
+      return next()
+    })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    // No capture was committed: the run reports the schema shortfall...
+    expect(result.structured).toBeUndefined()
+    expect(result.stopReason).toBe('error')
+    // ...the logged tool result is the blocked isError with the feedback...
+    const child = ctx.agents.get(run.id)!
+    const results = child.session.events.filter(e => e.type === 'tool/result')
+    expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
+    expect(JSON.stringify((results[0]!.data as { content: unknown }).content)).toContain('capture rejected by hook')
+    // ...and the turn CONTINUED past the blocked call (no captured veto):
+    // the model got to react to the failure with a second step.
+    expect(adapter.requests.length).toBe(2)
+    await run.dispose()
+  })
+
+  it('a post-execute accept-with-replacement still commits the capture', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
+    ])
+    ctx.on('tools/post-execute', (exec, _result, next) => {
+      if (exec.name === STRUCTURED_OUTPUT_TOOL) {
+        return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
+      }
+      return next()
+    })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 8 })
+    await run.dispose()
+  })
+
+  it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
+    const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
+    // A context-wide section stands in for the deployment persona: the
+    // instruction must APPEND to whatever the prompt pipeline assembled, not
+    // replace it (AgentOptions has no prompt field — the instruction is
+    // per-request wire state added by the final-request listener).
+    ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    const childRequest = adapter.requests.at(-1)!
+    expect(childRequest.system).toContain('You are a counter.')
+    expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
+    expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
+    await run.dispose()
+  })
+
+  it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
+    const { ctx, parent, adapter } = await setup([
+      textResponse('parent answer'),
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+    ])
+    parent.send([{ type: 'text', text: 'hello' }])
+    await parent.whenIdle()
+    expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    // The loop always assembles a base prompt (the harness identity section),
+    // so the instruction APPENDS — never replaces.
+    const childSystem = adapter.requests.at(-1)!.system!
+    expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
+    expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
+    await run.dispose()
+  })
+
+  describe('final-request enforcement (the prepend agent/request listener)', () => {
+    it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
+      // Run-scoped acquisition means a plain deployment never registers the
+      // tool at all; the strip branch exists for the CONCURRENT case — a plain
+      // agent taking a turn while some structured child holds the runtime open.
+      const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
+      const hold = acquireStructuredRuntime(ctx)
+      parent.send([{ type: 'text', text: 'hello' }])
+      await parent.whenIdle()
+      // The placeholder IS in the registry during this turn; the assembly the
+      // loop rendered must not carry it for an agent without a structured run.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+      hold.release()
+    })
+
+    it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
+      const { ctx, parent, adapter } = await setup([
+        // Parent turn (a plain agent): must NOT see the tool.
+        textResponse('parent answer'),
+        // Child turn: must see it, with the run's schema.
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
+      ])
+      parent.send([{ type: 'text', text: 'hello' }])
+      await parent.whenIdle()
+      expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      await run.result
+      const childRequest = adapter.requests[1]!
+      expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
+      const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+      expect(entry.parameters).toEqual(SCHEMA)
+      await run.dispose()
+    })
+
+    it('two concurrent structured children each see their OWN schema', async () => {
+      const otherSchema: StructuredOutputSchema = {
+        type: 'object',
+        properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
+        required: ['verdict'],
+      }
+      const { ctx, parent, adapter } = await setup([
+        (options: GenerateOptions) => {
+          // Answer with whatever schema this child was given — proves each
+          // request carried the right one regardless of scheduling order.
+          const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+          const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
+            ? { verdict: 'real' }
+            : { answer: 1 }
+          return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
+        },
+        (options: GenerateOptions) => {
+          const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+          const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
+            ? { verdict: 'real' }
+            : { answer: 1 }
+          return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
+        },
+      ])
+      const runA = ctx.subagents.start('spawn', structuredRequest(parent))
+      const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
+      const [a, b] = await Promise.all([runA.result, runB.result])
+      expect(a.structured).toEqual({ answer: 1 })
+      expect(b.structured).toEqual({ verdict: 'real' })
+      const schemas = adapter.requests.map(request =>
+        request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
+      expect(schemas).toContainEqual(SCHEMA)
+      expect(schemas).toContainEqual(otherSchema)
+      await runA.dispose()
+      await runB.dispose()
+    })
+
+    it('wins against a downstream listener that REPLACES the assembly object', async () => {
+      const { ctx, parent, adapter } = await setup([
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
+      ])
+      // A downstream (non-prepend) listener that returns a brand-new assembly —
+      // the composition caveat that erases cooperative mutations. Registered
+      // AFTER the runtime's prepend listener, so it runs INSIDE it.
+      ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
+        const replaced = await next()
+        return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
+      })
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      const result = await run.result
+      expect(result.structured).toEqual({ answer: 5 })
+      const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
+      expect(entry).toBeDefined()
+      expect(entry!.parameters).toEqual(SCHEMA)
+      await run.dispose()
+    })
+
+    it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
+      const { parent, adapter } = await setup([
+        // The registry contributes the placeholder via prompt assembly, so
+        // tools is an array in the raw request — but after stripping the
+        // placeholder (its ONLY entry), the field must not be re-added as a
+        // different shape.
+        textResponse('plain'),
+      ])
+      parent.send([{ type: 'text', text: 'q' }])
+      await parent.whenIdle()
+      const request = adapter.requests[0]!
+      expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+      await new Promise(resolve => setTimeout(resolve, 0))
+    })
+
+    it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
+      // Drive ctx.systemPrompt.assemble directly — the enforcement listener
+      // must tolerate a context with NO agent (a bare diagnostic assemble)
+      // and shape a structured agent's assembly on the same path the loop
+      // renders and logs as the request header.
+      const { ctx, parent } = await setup([])
+      const acquisition = acquireStructuredRuntime(ctx)
+      // Bare assemble WHILE the runtime is live: the no-agent branch must
+      // strip the registered placeholder (before the acquisition there is
+      // nothing to strip — run-scoped registration).
+      const bare = await ctx.systemPrompt.assemble({})
+      expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+
+      acquisition.attach(parent, SCHEMA)
+      const shaped = await ctx.systemPrompt.assemble({ agent: parent })
+      expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
+      expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
+      // The demand travels with the tool: the instruction renders LAST
+      // (appended post-next(); renderPrompt joins in array order).
+      expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
+      acquisition.detach(parent)
+      acquisition.release()
+    })
+  })
+
+  describe('runtime lifetime (refcount: live structured runs)', () => {
+    it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
+      const { ctx, parent } = await setup([
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
+      ])
+      // No always-on global state: a context that has run no structured child
+      // carries no capture tool.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      const result = await run.result
+      // The capture succeeded — the registrations existed while the run lived.
+      expect(result.structured).toEqual({ answer: 4 })
+      // The run's settle released the last acquisition.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      await run.dispose()
+    })
+
+    it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
+      const { ctx, parent } = await setup([
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+        toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
+      ])
+      const first = ctx.subagents.start('spawn', structuredRequest(parent))
+      const second = ctx.subagents.start('spawn', structuredRequest(parent))
+      const [a, b] = await Promise.all([first.result, second.result])
+      expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      await first.dispose()
+      await second.dispose()
+    })
+
+    it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
+      const ctx = new Context()
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      const first = acquireStructuredRuntime(ctx)
+      const second = acquireStructuredRuntime(ctx)
+      first.release()
+      first.release()
+      // The second holder still keeps the tool registered.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      second.release()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
+      // The Loader starts sibling plugins concurrently, so a backend can
+      // acquire the runtime before dsh-tools has applied. The capture tool
+      // must then register as soon as `tools` exists — via the inject fiber,
+      // not by deferring the backend (which would reorder the prompt's tools).
+      const ctx = new Context()
+      const acquisition = acquireStructuredRuntime(ctx)
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      // Fiber activation completes asynchronously after the service appears.
+      await new Promise(resolve => setImmediate(resolve))
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      acquisition.release()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
+      const ctx = new Context()
+      const acquisition = acquireStructuredRuntime(ctx)
+      acquisition.release()
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      await new Promise(resolve => setImmediate(resolve))
+      // The disposed fiber never fires: nothing registers after the fact.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
+      const { ctx, parent } = await setup([])
+      const acquisition = acquireStructuredRuntime(ctx)
+      expect(acquisition.captured(parent)).toBeUndefined()
+      acquisition.attach(parent, SCHEMA)
+      expect(acquisition.captured(parent)).toBeUndefined()
+      acquisition.detach(parent)
+      acquisition.detach(parent)
+      acquisition.release()
+      // That manual acquisition was the ONLY holder - release disposes.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+  })
+
+  it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
+    const { ctx, parent } = await setup([])
+    // Hold the runtime open (run-scoped: nothing is registered otherwise) so
+    // the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
+    const hold = acquireStructuredRuntime(ctx)
+    const result = await ctx.tools.execute({
+      callId: 'x' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+      agent: parent,
+    })
+    expect(result.isError).toBe(true)
+    expect(JSON.stringify(result.content)).toContain('only available to subagents')
+    hold.release()
+  })
+
+  it('a structured_output call with NO calling agent at all is an isError', async () => {
+    const { ctx } = await setup([])
+    const hold = acquireStructuredRuntime(ctx)
+    const result = await ctx.tools.execute({
+      callId: 'x' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+    })
+    expect(result.isError).toBe(true)
+    hold.release()
+  })
+})

+ 6 - 0
packages/subagent/subagent-inprocess/tsconfig.json

@@ -25,6 +25,12 @@
     },
     {
       "path": "../subagent"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../core/tools"
     }
   ]
 }

+ 1 - 1
packages/subagent/subagent-spawn/README.md

@@ -10,7 +10,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
 
 ## Capabilities
 
-`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs).
+`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's [structured runtime](../subagent-inprocess/README.md) (acquired per structured run inside the driver — this backend registers nothing at apply). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
 
 ## Config
 

+ 16 - 4
packages/subagent/subagent-spawn/src/index.ts

@@ -9,6 +9,11 @@
  * ({@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 via the driver's shared
+ * structured runtime: the backend acquires it for its plugin lifetime (so the
+ * capture tool and request-shaping listeners exist before any run), and each
+ * structured run holds its own acquisition until it settles.
+ *
  * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
  *
  * @module @deepseek-ai/dsh-subagent-spawn
@@ -20,6 +25,11 @@ 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's structured runtime
+// (acquired per structured RUN, not at apply) gates its own capture-tool
+// registration on `tools` availability, so this backend's apply timing — and
+// with it the provider-mirroring delegation tool's position in the
+// model-visible tool list — stays what it was before structured output existed.
 export const inject = ['subagents', 'agents']
 
 /** Config: the registry name to register the provider under. */
@@ -34,11 +44,12 @@ export const Config: z<Config> = z.object({
 
 /**
  * The spawn provider. Supports `depthLimit` (it constructs the child, so it can
- * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut —
- * a request that needs either is rejected by the service before `start` runs.
+ * enforce a recursion cap) and `outputSchema` (via the shared in-process
+ * structured runtime); NOT `toolFilter` in this cut — a request that needs it
+ * is rejected by the service before `start` runs.
  */
 class SpawnProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
   // Context contract: a spawned child starts fresh — it never sees the parent conversation.
   readonly inheritsParentContext = false
 
@@ -46,7 +57,8 @@ class SpawnProvider implements SubagentProvider {
 
   start(request: SubagentStartRequest) {
     // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
-    // depth, drives the one-shot, and maps the result.
+    // depth, drives the one-shot (including the structured capture when the
+    // request carries an outputSchema), and maps the result.
     return startInProcessRun(this.ctx, request, { providerName: this.name })
   }
 }

+ 52 - 4
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -10,9 +10,9 @@ import { SessionId } from '@deepseek-ai/dsh-session'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import SubagentService from '@deepseek-ai/dsh-subagent'
-import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import * as spawn from '../src/index.ts'
-import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
+import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
@@ -241,10 +241,10 @@ describe('dsh-subagent-spawn', () => {
     await parentHandle.dispose()
   })
 
-  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
     const { ctx } = await setup([])
     const provider = ctx.subagents.getProvider('spawn')!
-    expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+    expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
@@ -257,6 +257,54 @@ describe('dsh-subagent-spawn', () => {
     expect(ctx.subagents.list()).toEqual([])
   })
 
+  it('captures structured output through the shipped plugin (driver runtime, plugin wiring)', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
+    ])
+    const run = ctx.subagents.start('spawn', {
+      prompt: [{ type: 'text', text: 'produce the answer' }],
+      parent,
+      outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 42 })
+    // Run-scoped runtime: the settle released the last acquisition.
+    expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    await run.dispose()
+  })
+
+  it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
+    // Rebuild the stack by hand so we hold the backend's fiber.
+    const ctx = new Context()
+    const adapter = new MockAdapter(['hang'])
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(Invariants)
+    await ctx.plugin(AgentLoop, { agents: [] })
+    await ctx.plugin(SubagentService)
+    const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
+    ctx.llm.registerAdapter(['mock'], adapter)
+    const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+    const run = ctx.subagents.start('spawn', {
+      prompt: [{ type: 'text', text: 'q' }],
+      parent,
+      outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
+    })
+    // Let the child's step start streaming, then unload the backend. The
+    // backend owns the child agent, so the unload tears the child down and
+    // the run settles — releasing its own runtime acquisition on the way out.
+    await new Promise(resolve => setTimeout(resolve, 30))
+    await fiber.dispose()
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    await run.dispose()
+  })
+
   it('has the namespace-plugin export shape (no stray default)', () => {
     expect('default' in spawn).toBe(false)
     expect(spawn.name).toBe('subagent-spawn')

+ 9 - 5
packages/subagent/subagent/src/types.ts

@@ -8,7 +8,7 @@
 
 import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
+import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
 
 /**
  * Which START-TIME features a provider supports. Checked by the service
@@ -56,12 +56,16 @@ export interface SubagentStartRequest {
   /** Per-child agent options (model, system prompt). */
   agentOptions?: AgentOptions
   /**
-   * Optional structured-output schema. When set AND the provider's
-   * {@link SubagentCapabilities.outputSchema} is `true`, the child's final
-   * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
+   * 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.
    */
-  outputSchema?: SchemaSpec
+  outputSchema?: StructuredOutputSchema
   /**
    * Optional recursion cap (max delegation depth below this child). Requires
    * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.

+ 2 - 2
packages/subagent/subagent/tests/service.spec.ts

@@ -178,7 +178,7 @@ describe('SubagentService', () => {
 
   describe('start-time capability validation (fail loud, before any child)', () => {
     it.each([
-      { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
+      { field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) },
       { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
       { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
     ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
@@ -203,7 +203,7 @@ describe('SubagentService', () => {
       await ctx.plugin(SubagentService)
       const provider = new StubProvider('strong', ALL_CAPS)
       ctx.subagents.registerProvider(provider)
-      ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
+      ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
       expect(provider.startCount).toBe(1)
     })
   })

+ 2 - 2
packages/support/subagent-mock/tests/subagent-mock.spec.ts

@@ -41,13 +41,13 @@ describe('dsh-subagent-mock', () => {
 
   it('surfaces a structured result when the request carries an outputSchema', async () => {
     const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
-    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
+    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
     await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
   })
 
   it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
     const ctx = await mount({ reply: 'fallback reply' })
-    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
+    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
     await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
   })
 

+ 1 - 0
packages/ui/acp-agent/README.md

@@ -24,6 +24,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
 |---|---|---|
 | `model` | (required) | the per-session agent template the bridge creates agents from |
 | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
+| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
 | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
 
 The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).

+ 1 - 0
packages/ui/acp-agent/package.json

@@ -45,6 +45,7 @@
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-acp": "workspace:^",
     "@deepseek-ai/dsh-agent-core": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "cordis": "^4.0.0-rc.6",
     "schemastery": "^3.17.0"

+ 9 - 1
packages/ui/acp-agent/src/index.ts

@@ -42,7 +42,8 @@ export const name = 'acp-agent'
  * App config: the swappable per-deployment values. `model` configures the
  * agent template the ACP bridge creates each session's agent from (NOT a
  * pre-created agent — ACP creates agents at `session/new`); `persona` is the
- * deployment persona (forwarded to the system-prompt plugin);
+ * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
+ * the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * `persistenceRoot` is the JSONL backend's directory.
  */
 export interface Config {
@@ -50,6 +51,8 @@ export interface Config {
   model: string
   /** Deployment persona (the system-prompt plugin's `persona` config). */
   persona?: string
+  /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
+  toolOrder?: string[]
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
 }
@@ -57,6 +60,10 @@ export interface Config {
 export const Config: z<Config> = z.object({
   model: z.string().required(),
   persona: z.string(),
+  // The array default is forced to undefined: ABSENT means "lexicographic
+  // order" (the owning dsh-system-prompt schema does the same), while
+  // schemastery's native [] default would read as an invalid configured list.
+  toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   persistenceRoot: z.string().default('./.sessions'),
 })
 
@@ -70,6 +77,7 @@ export const Config: z<Config> = z.object({
 export function apply(ctx: Context, config: Config): void {
   ctx.plugin(agentCore, {
     ...config.persona !== undefined ? { persona: config.persona } : {},
+    ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
   })
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(acp, { model: config.model })

+ 22 - 0
packages/ui/acp-agent/tests/acp-agent.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
+import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
 import * as acpAgent from '../src/index.ts'
 
 /**
@@ -52,6 +53,27 @@ describe('dsh-acp-agent composition', () => {
     expect(acpAgent.Config).toBeDefined()
   })
 
+  it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
+    const ctx = await mount({
+      model: 'mock',
+      toolOrder: ['zulu', TOOL_ORDER_REST],
+      persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
+    })
+    // The bundle's own bash tools pend on the absent `ctx.bash` executor in
+    // this providerless mount, so register two plain tools to order.
+    for (const name of ['alpha', 'zulu']) {
+      ctx.get('tools')!.register({
+        name,
+        description: name,
+        parameters: {},
+        execute: async () => [],
+      })
+    }
+    const assembly = await ctx.get('systemPrompt')!.assemble()
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+    await ctx.fiber.dispose()
+  })
+
   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

+ 1 - 0
packages/ui/stdio-agent/README.md

@@ -25,6 +25,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 |---|---|---|
 | `model` | (required) | the pre-created `main` agent's model |
 | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
+| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
 | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
 | `welcome` | `ready.` | the stdin-chat banner |
 | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |

+ 1 - 0
packages/ui/stdio-agent/package.json

@@ -50,6 +50,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-agent-core": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "cordis": "^4.0.0-rc.6",

+ 9 - 1
packages/ui/stdio-agent/src/index.ts

@@ -53,7 +53,8 @@ export const name = 'stdio-agent'
  * App config: the swappable per-demo values, each routed to where the app wires
  * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
  * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
- * the deployment persona (forwarded to the system-prompt plugin);
+ * the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
+ * is the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
  */
 export interface Config {
@@ -61,6 +62,8 @@ export interface Config {
   model: string
   /** Deployment persona (the system-prompt plugin's `persona` config). */
   persona?: string
+  /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
+  toolOrder?: string[]
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
   /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -76,6 +79,10 @@ export interface Config {
 export const Config: z<Config> = z.object({
   model: z.string().required(),
   persona: z.string(),
+  // The array default is forced to undefined: ABSENT means "lexicographic
+  // order" (the owning dsh-system-prompt schema does the same), while
+  // schemastery's native [] default would read as an invalid configured list.
+  toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   persistenceRoot: z.string().default('./.sessions'),
   welcome: z.string().default('ready.'),
   resumeSessionId: z.string(),
@@ -92,6 +99,7 @@ export function apply(ctx: Context, config: Config): void {
   ctx.plugin(ConsoleExporter)
   ctx.plugin(agentCore, {
     ...config.persona !== undefined ? { persona: config.persona } : {},
+    ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
     agents: [{
       id: AgentId('main'),
       model: config.model,

+ 4 - 3
packages/ui/stdio-agent/tests/built-bin.e2e.ts

@@ -76,9 +76,10 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
     await mkdir(dirname(target), { recursive: true })
     await symlink(abs, target)
   }
-  // The example's mock model + echo tool are example-local TS plugins (Node 24+
-  // strips types natively, so plain `node` loads them); they import the workspace
-  // packages the symlinked node_modules now provides.
+  // The example's mock model + echo tool are example-local TS plugins (Node
+  // 22.19+ — the engines floor — strips types natively, so plain `node` loads
+  // them); they import the workspace packages the symlinked node_modules now
+  // provides.
   await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
   await writeFile(join(dir, 'cordis.yml'), [
     '- id: mock-llm',

+ 22 - 0
packages/ui/stdio-agent/tests/stdio-agent.spec.ts

@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import { AgentId } from '@deepseek-ai/dsh-agent'
+import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
 import * as stdioAgent from '../src/index.ts'
 
 /**
@@ -74,6 +75,27 @@ describe('dsh-stdio-agent app', () => {
     expect(stdioAgent.Config).toBeDefined()
   })
 
+  it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
+    const ctx = await mount({
+      model: 'mock',
+      toolOrder: ['zulu', TOOL_ORDER_REST],
+      persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
+    })
+    // The bundle's own bash tools pend on the absent `ctx.bash` executor in
+    // this providerless mount, so register two plain tools to order.
+    for (const name of ['alpha', 'zulu']) {
+      ctx.get('tools')!.register({
+        name,
+        description: name,
+        parameters: {},
+        execute: async () => [],
+      })
+    }
+    const assembly = await ctx.get('systemPrompt')!.assemble()
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+    await ctx.fiber.dispose()
+  })
+
   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

+ 1 - 1
packages/web/web-fetch-local/src/provider.ts

@@ -1,6 +1,6 @@
 /**
  * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public
- * HTTP(S) URL with the platform-native `fetch` (Node 24) and returns a status
+ * 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

+ 1 - 1
packages/web/web-search-deepseek/src/provider.ts

@@ -12,7 +12,7 @@
  * `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` (Node 24), mirroring
+ * 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`.

+ 1 - 1
packages/web/web-search-exa/src/provider.ts

@@ -6,7 +6,7 @@
  * `title`, the first highlight as `snippet`, and `publishedDate` as
  * `publishedAt`.
  *
- * Network requests use platform-native `fetch` (Node 24), mirroring
+ * 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.
  *
  * @module @deepseek-ai/dsh-web-search-exa/provider

+ 1 - 1
packages/web/web-search-perplexity/src/provider.ts

@@ -5,7 +5,7 @@
  * structured `search_results[]` for `sources[]`, falling back to the URL-only
  * `citations[]` when `search_results` is absent.
  *
- * Network requests use platform-native `fetch` (Node 24), mirroring
+ * Network requests use platform-native `fetch` at the repo's Node floor, mirroring
  * `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape
  * is a provider-private detail and does NOT make this provider depend on
  * `ctx.llm`.

+ 46 - 17
pnpm-lock.yaml

@@ -21,8 +21,8 @@ importers:
         specifier: ^4.0.4
         version: 4.0.4
       '@types/node':
-        specifier: ^25.3.5
-        version: 25.9.3
+        specifier: ^22.20.0
+        version: 22.20.0
       '@vitest/coverage-v8':
         specifier: ^4.1.8
         version: 4.1.8(vitest@4.1.8)
@@ -70,10 +70,10 @@ importers:
         version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
       vite-tsconfig-paths:
         specifier: ^6.1.1
-        version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+        version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
       vitest:
         specifier: ^4.1.8
-        version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+        version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
 
   packages/bash/bash:
     devDependencies:
@@ -899,6 +899,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
@@ -947,6 +950,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
@@ -2348,6 +2354,9 @@ packages:
   '@types/ms@2.1.0':
     resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
 
+  '@types/node@22.20.0':
+    resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
+
   '@types/node@25.9.3':
     resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
 
@@ -2360,6 +2369,9 @@ packages:
   '@types/tough-cookie@4.0.5':
     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
 
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
@@ -3811,6 +3823,9 @@ packages:
   unconfig-core@7.5.0:
     resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
 
+  undici-types@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
   undici-types@7.24.6:
     resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
 
@@ -5080,6 +5095,10 @@ snapshots:
 
   '@types/ms@2.1.0': {}
 
+  '@types/node@22.20.0':
+    dependencies:
+      undici-types: 6.21.0
+
   '@types/node@25.9.3':
     dependencies:
       undici-types: 7.24.6
@@ -5090,6 +5109,9 @@ snapshots:
 
   '@types/tough-cookie@4.0.5': {}
 
+  '@types/trusted-types@2.0.7':
+    optional: true
+
   '@types/unist@3.0.3': {}
 
   '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
@@ -5183,7 +5205,10 @@ snapshots:
       '@typescript-eslint/types': 8.61.0
       eslint-visitor-keys: 5.0.1
 
-  '@upsetjs/venn.js@2.0.0': {}
+  '@upsetjs/venn.js@2.0.0':
+    optionalDependencies:
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
 
   '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
     dependencies:
@@ -5197,7 +5222,7 @@ snapshots:
       obug: 2.1.3
       std-env: 4.1.0
       tinyrainbow: 3.1.0
-      vitest: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+      vitest: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
 
   '@vitest/expect@4.1.8':
     dependencies:
@@ -5208,13 +5233,13 @@ snapshots:
       chai: 6.2.2
       tinyrainbow: 3.1.0
 
-  '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))':
+  '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))':
     dependencies:
       '@vitest/spy': 4.1.8
       estree-walker: 3.0.3
       magic-string: 0.30.21
     optionalDependencies:
-      vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
+      vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
 
   '@vitest/pretty-format@4.1.8':
     dependencies:
@@ -5573,7 +5598,9 @@ snapshots:
 
   diff@9.0.0: {}
 
-  dompurify@3.4.11: {}
+  dompurify@3.4.11:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
 
   dts-resolver@3.0.0(oxc-resolver@11.20.0):
     optionalDependencies:
@@ -6808,6 +6835,8 @@ snapshots:
       '@quansync/fs': 1.0.0
       quansync: 1.0.0
 
+  undici-types@6.21.0: {}
+
   undici-types@7.24.6: {}
 
   undici@7.28.0: {}
@@ -6837,17 +6866,17 @@ snapshots:
 
   uuid@14.0.1: {}
 
-  vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
+  vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
     dependencies:
       debug: 4.4.3
       globrex: 0.1.2
       tsconfck: 3.1.6(typescript@6.0.3)
-      vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
+      vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
     transitivePeerDependencies:
       - supports-color
       - typescript
 
-  vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0):
+  vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0):
     dependencies:
       lightningcss: 1.32.0
       picomatch: 4.0.4
@@ -6855,17 +6884,17 @@ snapshots:
       rolldown: 1.0.3
       tinyglobby: 0.2.17
     optionalDependencies:
-      '@types/node': 25.9.3
+      '@types/node': 22.20.0
       esbuild: 0.28.1
       fsevents: 2.3.3
       jiti: 2.7.0
       tsx: 4.22.4
       yaml: 2.9.0
 
-  vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
+  vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
     dependencies:
       '@vitest/expect': 4.1.8
-      '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+      '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
       '@vitest/pretty-format': 4.1.8
       '@vitest/runner': 4.1.8
       '@vitest/snapshot': 4.1.8
@@ -6882,10 +6911,10 @@ snapshots:
       tinyexec: 1.2.4
       tinyglobby: 0.2.17
       tinyrainbow: 3.1.0
-      vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
+      vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
       why-is-node-running: 2.3.0
     optionalDependencies:
-      '@types/node': 25.9.3
+      '@types/node': 22.20.0
       '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
       jsdom: 29.1.1
     transitivePeerDependencies:

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1660,
+  "AGENTS.md": 1691,
   "docs/AGENTS.md": 1315,
   "docs/architecture.md": 1630,
   "docs/cordis-primer.md": 550,

+ 2 - 3
scripts/doc-typecheck.ts

@@ -25,9 +25,8 @@
  */
 
 import { execFileSync } from 'node:child_process'
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { join, relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import ts from 'typescript'
 
 const root = resolve(import.meta.dirname, '..')
@@ -139,7 +138,7 @@ const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages
 
 const files: string[] = []
 for (const pattern of markdownGlobs) {
-  for await (const match of glob(pattern, { cwd: root })) files.push(resolve(root, match))
+  for (const match of globSync(pattern, { cwd: root })) files.push(resolve(root, match))
 }
 files.sort()
 

+ 4 - 0
scripts/type-equiv.manifest.json

@@ -45,6 +45,10 @@
     { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
     { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
     { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
+    { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" },
+    { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" },
+    { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
+    { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" },
 
     { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
     { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },

+ 2 - 3
scripts/verify-doc-refs.ts

@@ -26,9 +26,8 @@
  * Run: `tsx scripts/verify-doc-refs.ts`.
  */
 
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync, globSync, readFileSync } from 'node:fs'
 import { relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -77,7 +76,7 @@ function findViolations(absPath: string): Violation[] {
 const all: Violation[] = []
 let checked = 0
 for (const pattern of PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) {
+  for (const match of globSync(pattern, { cwd: root })) {
     if (isExcluded(match)) continue
     checked++
     all.push(...findViolations(resolve(root, match)))

+ 2 - 3
scripts/verify-md-links.ts

@@ -32,9 +32,8 @@
  * Run: `tsx scripts/verify-md-links.ts`.
  */
 
-import { existsSync, readFileSync, realpathSync } from 'node:fs'
+import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
 import { dirname, relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
@@ -134,7 +133,7 @@ const seen = new Set<string>()
 const all: Violation[] = []
 let checked = 0
 for (const pattern of PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) {
+  for (const match of globSync(pattern, { cwd: root })) {
     const abs = resolve(root, match)
     // CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
     // matched twice (or via symlink) is checked once.

+ 2 - 3
scripts/verify-md-wrap.ts

@@ -25,9 +25,8 @@
  * Run: `tsx scripts/verify-md-wrap.ts`.
  */
 
-import { readFileSync, realpathSync } from 'node:fs'
+import { globSync, readFileSync, realpathSync } from 'node:fs'
 import { relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
@@ -76,7 +75,7 @@ const seen = new Set<string>()
 const all: Violation[] = []
 let checked = 0
 for (const pattern of PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) {
+  for (const match of globSync(pattern, { cwd: root })) {
     const abs = resolve(root, match)
     // CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
     // matched twice (or via symlink) is checked once.

+ 2 - 3
scripts/verify-mermaid.ts

@@ -12,9 +12,8 @@
  * Run: `tsx scripts/verify-mermaid.ts`.
  */
 
-import { readFileSync, realpathSync } from 'node:fs'
+import { globSync, readFileSync, realpathSync } from 'node:fs'
 import { resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
@@ -72,7 +71,7 @@ const blocks: Block[] = []
 const seen = new Set<string>()
 let checkedFiles = 0
 for (const pattern of PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) {
+  for (const match of globSync(pattern, { cwd: root })) {
     const real = realpathSync(resolve(root, match))
     if (seen.has(real)) continue
     seen.add(real)

+ 2 - 3
scripts/verify-package-paths.ts

@@ -39,9 +39,8 @@
  * Run: `tsx scripts/verify-package-paths.ts`.
  */
 
-import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
+import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
 import { relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -144,7 +143,7 @@ const all: Violation[] = []
 let checked = 0
 const seen = new Set<string>()
 for (const pattern of PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) {
+  for (const match of globSync(pattern, { cwd: root })) {
     if (isExcluded(match)) continue
     // Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
     const real = realpathSync(resolve(root, match))

+ 2 - 3
scripts/verify-translation-pairing.ts

@@ -44,9 +44,8 @@
  */
 
 import { createHash } from 'node:crypto'
-import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
 import { basename, join, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
@@ -211,7 +210,7 @@ function parse(content: string): Nodes {
 // Enumerate the scope once.
 const files = new Set<string>()
 for (const pattern of SCOPE_PATTERNS) {
-  for await (const match of glob(pattern, { cwd: root })) files.add(match)
+  for (const match of globSync(pattern, { cwd: root })) files.add(match)
 }
 const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
 const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()

+ 2 - 3
scripts/verify-type-equiv.ts

@@ -22,9 +22,8 @@
  * Run: `tsx scripts/verify-type-equiv.ts`.
  */
 
-import { readFileSync, existsSync } from 'node:fs'
+import { globSync, readFileSync, existsSync } from 'node:fs'
 import { resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
 import ts from 'typescript'
 
 const root = resolve(import.meta.dirname, '..')
@@ -148,7 +147,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym
 // as an orphan rather than silently skipped.
 const docSet = new Set<string>()
 for (const pattern of MARKDOWN_GLOBS) {
-  for await (const match of glob(pattern, { cwd: root })) docSet.add(match)
+  for (const match of globSync(pattern, { cwd: root })) docSet.add(match)
 }
 const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
 

Некоторые файлы не были показаны из-за большого количества измененных файлов