From 671cbe173eec053ac04b205419c6d4bf388cc6f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 6 Jul 2026 18:40:57 +0800 Subject: [PATCH 01/36] add dsh pre-push checks skill --- .agents/skills/dsh-pre-push-checks/SKILL.md | 118 ++++++++++++++++++ .../dsh-pre-push-checks/agents/openai.yaml | 4 + 2 files changed, 122 insertions(+) create mode 100644 .agents/skills/dsh-pre-push-checks/SKILL.md create mode 100644 .agents/skills/dsh-pre-push-checks/agents/openai.yaml diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md new file mode 100644 index 0000000000..a29c624bcf --- /dev/null +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -0,0 +1,118 @@ +--- +name: dsh-pre-push-checks +description: Use before pushing, force-pushing, marking ready for review, replying that checks pass, or bypassing a local hook on a deepseek-harness branch. Guides Codex to run the right local gates for the touched surface so CI is unlikely to fail after push, 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 and committing the merge. Do not push a conflict-resolution commit that has 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: + +```sh +pnpm run constraints +pnpm run typecheck +pnpm run lint +pnpm run doc-sync +pnpm run verify-module-graph +pnpm run test:coverage +pnpm run test:snapshot +pnpm run build +pnpm run hygiene +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 +``` + +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. Commit only after the relevant gates pass. +2. Let the normal pre-commit hook run. If it changes files, inspect and amend with a new commit rather than hiding the change. +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. diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml new file mode 100644 index 0000000000..6ad9b63935 --- /dev/null +++ b/.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." From e30f5e633d966cc0950645ccc8af687e44774544 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 6 Jul 2026 19:09:36 +0800 Subject: [PATCH 02/36] add demo smoke to pre-push skill --- .agents/skills/dsh-pre-push-checks/SKILL.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index a29c624bcf..5f3d84bd83 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -79,10 +79,15 @@ pnpm run test:coverage pnpm run test:snapshot pnpm run build pnpm run hygiene +out=$(printf 'echo ci smoke\n' | pnpm run demo:echo 2>&1) +printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' +printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' +ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null +rm -rf .sessions pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts ``` -Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior. +The `demo:echo` smoke validates the mock-model REPL path and leaves a session log; assert both transcript lines and then remove `.sessions`. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior. ## Handling Failures From cd9737d5696c29e234e69eb9a10dccf62121f00a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:09:30 +0800 Subject: [PATCH 03/36] Gate JSDoc completeness on every package export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New doc-sync gate verify-export-jsdoc walks every module-level exported name under packages/*/*/src and requires description prose everywhere, plus @param per parameter and @returns on non-void annotated returns for function-like exports, public class methods, properties, and accessors. The parsing + check helpers move out of gen-cordis-catalog.ts into a shared scripts/jsdoc.ts so 'documented' means one thing on both gated surfaces. Deliberate exemptions (documented in the RFC): heritage-declared class members (the seam declaration is the doc's one home — the one checker query in an otherwise pure-AST walk), cordis plugin-protocol slots (name/inject/reusable/Config/apply, top-level and static), constructors, overload implementations, declare-module augmentation bodies, and re-export statements (checked at the defining module). The 203 under-documented exports the gate found at adoption are filled in this change, so the gate lands green; generated catalogs/graphs are regenerated for the shifted line pointers. RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md --- AGENTS.md | 2 +- docs/cordis-catalog/events.md | 22 +- docs/cordis-catalog/services.md | 10 +- docs/development.i18n.yaml | 4 +- docs/development.md | 1 + docs/development.zh.md | 1 + docs/event-producer-consumer.md | 22 +- docs/persistence-catalog/log-events.md | 30 +- docs/rfc/INDEX.md | 1 + .../2026-07-06-export-surface-jsdoc-gate.md | 42 ++ package.json | 3 +- packages/bash/bash-local/src/run.ts | 29 +- packages/bash/bash/src/types.ts | 13 +- packages/bash/tool-bash/src/index.ts | 2 + packages/compact/compact-basic/src/index.ts | 26 +- packages/compact/compact-basic/src/types.ts | 3 + packages/core/agent-loop/src/agent.ts | 6 + packages/core/agent-loop/src/inbox.ts | 30 +- packages/core/agent-loop/src/index.ts | 4 + packages/core/agent-loop/src/loop.ts | 11 +- packages/core/agent-loop/src/request-log.ts | 5 +- packages/core/agent/src/types.ts | 18 +- .../agent/tests/verify-export-jsdoc.spec.ts | 296 +++++++++++++ packages/core/session/src/index.ts | 8 + packages/core/session/src/json.ts | 4 + packages/core/session/src/repair.ts | 2 + packages/core/session/src/surface.ts | 4 + packages/core/session/src/tool-pairing.ts | 6 + packages/core/session/src/types.ts | 9 +- packages/core/system-prompt/src/index.ts | 5 + packages/core/tools/src/schema.ts | 13 + packages/fs/fs-local/src/fsio.ts | 50 ++- packages/fs/fs-local/src/index.ts | 1 + packages/fs/fs/src/types.ts | 14 +- packages/fs/tool-fs/src/diff.ts | 6 + packages/fs/tool-fs/src/edit.ts | 20 +- packages/fs/tool-fs/src/read-render.ts | 11 +- packages/fs/tool-fs/src/read.ts | 13 +- packages/fs/tool-fs/src/session-cwd.ts | 6 +- packages/fs/tool-fs/src/write.ts | 19 +- packages/hooks/hook-protocol/src/codec.ts | 6 + packages/hooks/hook-protocol/src/events.ts | 11 +- packages/hooks/hook-protocol/src/matcher.ts | 4 + packages/hooks/hook-protocol/src/merge.ts | 2 + packages/hooks/hook-protocol/src/runner.ts | 5 + packages/hooks/hooks-claude/src/config.ts | 10 +- packages/hooks/hooks-codex/src/config.ts | 2 + packages/llm/llm-deepseek/src/adapter.ts | 8 +- packages/llm/llm-deepseek/src/index.ts | 6 + packages/llm/llm-deepseek/src/serialize.ts | 11 +- packages/llm/llm-deepseek/src/sse.ts | 2 + packages/llm/llm-deepseek/src/translate.ts | 10 +- packages/llm/llm-deepseek/src/types.ts | 18 + packages/llm/llm-pi-ai/src/adapter.ts | 10 +- packages/llm/llm-pi-ai/src/convert.ts | 17 +- packages/llm/llm-pi-ai/src/index.ts | 5 + packages/llm/llm/src/assembler.ts | 15 +- packages/llm/llm/src/attribution.ts | 4 + packages/llm/llm/src/brand.ts | 6 +- packages/llm/llm/src/error.ts | 7 +- packages/llm/llm/src/index.ts | 6 +- packages/llm/llm/src/never.ts | 3 + packages/llm/llm/src/types.ts | 4 + .../session-persistence-jsonl/src/format.ts | 42 +- .../session-persistence-jsonl/src/index.ts | 1 + .../session-persistence-sqlite/src/schema.ts | 19 +- .../session-persistence/src/coordinator.ts | 5 + .../session-persistence/src/index.ts | 4 + packages/subagent/subagent-acp/src/run.ts | 31 +- packages/subagent/subagent-fork/src/index.ts | 2 + .../subagent/subagent-inprocess/src/index.ts | 13 +- packages/subagent/subagent/src/types.ts | 1 + packages/support/llm-replay/src/index.ts | 14 + packages/ui/acp/src/codec.ts | 8 + packages/ui/acp/src/index.ts | 38 +- packages/ui/app-boot/src/index.ts | 16 + packages/ui/stdio-agent/src/stdio-chat.ts | 4 + packages/web/tool-web/src/fetch.ts | 38 +- packages/web/tool-web/src/html.ts | 4 + packages/web/tool-web/src/index.ts | 1 + packages/web/tool-web/src/search.ts | 33 +- packages/web/web-fetch-local/src/index.ts | 1 + packages/web/web-fetch-local/src/policy.ts | 20 + packages/web/web-search-deepseek/src/index.ts | 1 + .../web/web-search-deepseek/src/provider.ts | 8 + packages/web/web-search-exa/src/index.ts | 1 + packages/web/web-search-exa/src/provider.ts | 14 +- .../web/web-search-perplexity/src/index.ts | 1 + .../web/web-search-perplexity/src/provider.ts | 12 +- scripts/gen-cordis-catalog.ts | 191 +------- scripts/jsdoc.ts | 216 ++++++++++ scripts/verify-export-jsdoc.ts | 408 ++++++++++++++++++ 92 files changed, 1802 insertions(+), 289 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md create mode 100644 packages/core/agent/tests/verify-export-jsdoc.spec.ts create mode 100644 scripts/jsdoc.ts create mode 100644 scripts/verify-export-jsdoc.ts diff --git a/AGENTS.md b/AGENTS.md index 0fbc56a646..8e6eb7c4f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR ## Type safety and documentation -Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example). +Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones — heritage-declared members, plugin-protocol slots, and constructors exempt ([export-gate RFC](docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md)). Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example). Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 753cf8f4d0..237a7b2469 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:385`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -109,7 +109,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -121,7 +121,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -133,7 +133,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -145,7 +145,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:408`](../../packages/core/agent/src/types.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0221946118..4397f58cab 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:67`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -124,7 +124,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:84`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -146,7 +146,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:371`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:379`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -189,7 +189,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:203`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index cef11b8352..119f058ad6 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: f032764fff29baaca007211db8b69d9a5129078f -development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9 +development.md: 578fa619b8b3c7f5a306c8ad9cd880ab648b9b0c +development.zh.md: e0189ae9b4c42f3f61ccc30b9205ff11f64f1fec diff --git a/docs/development.md b/docs/development.md index f032764fff..578fa619b8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -98,6 +98,7 @@ pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source pnpm run verify-cordis-catalog # fail if either cordis catalog is stale +pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions pnpm run verify-doc-graphs # fail if generated relationship docs are stale pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree diff --git a/docs/development.zh.md b/docs/development.zh.md index 3a650d03ce..e0189ae9b4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -98,6 +98,7 @@ pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source pnpm run verify-cordis-catalog # fail if either cordis catalog is stale +pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions pnpm run verify-doc-graphs # fail if generated relationship docs are stale pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24a2793826..2260c724b4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,17 +7,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:392`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:385`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog/log-events.md b/docs/persistence-catalog/log-events.md index 5d6f23f6a8..9801a3cfec 100644 --- a/docs/persistence-catalog/log-events.md +++ b/docs/persistence-catalog/log-events.md @@ -23,7 +23,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:298`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -35,7 +35,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](../core-data-structures/core.md) · [TokenUsage](../core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:305`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts) ### `compact/*` @@ -83,7 +83,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:302`](../../packages/core/session/src/types.ts) ### `hook/*` @@ -119,7 +119,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:290`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts) ### `request/*` @@ -131,7 +131,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:350`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:356`](../../packages/core/session/src/types.ts) #### `request/header-delta` — log-only @@ -141,7 +141,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } ``` -Source: [`packages/core/session/src/types.ts:361`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:367`](../../packages/core/session/src/types.ts) ### `steering/*` @@ -155,7 +155,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:323`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:329`](../../packages/core/session/src/types.ts) ### `step/*` @@ -167,7 +167,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:277`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -177,7 +177,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:275`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:281`](../../packages/core/session/src/types.ts) ### `todo/*` @@ -193,7 +193,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:337`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:343`](../../packages/core/session/src/types.ts) ### `tool/*` @@ -207,7 +207,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:317`](../../packages/core/session/src/types.ts) #### `tool/result` — surface @@ -219,7 +219,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:321`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../../packages/core/session/src/types.ts) ### `turn/*` @@ -233,7 +233,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -245,7 +245,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:267`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts) ### `user/*` @@ -259,4 +259,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 921c6389e8..f6d62e855e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -142,6 +142,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | | [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 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md new file mode 100644 index 0000000000..fa92fbcb3d --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -0,0 +1,42 @@ +# RFC: Export-surface JSDoc gate + +Status: implemented + +## Problem + +The [cordis JSDoc completeness gate](2026-07-04-cordis-jsdoc-completeness-gate.md) made undocumented parameters and results impossible on the cordis surface — `interface Events` members and `ctx.` service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkable only by review everywhere else, and nothing at all asked for `@param`/`@returns` on ordinary exported functions. A survey at adoption found 203 under-documented module-level exports across 34 packages: seam-adjacent helpers (`runBash`, `readForEdit`, `htmlToMarkdown`), format codecs, whole undocumented interfaces and type aliases — exactly the names an IDE consumer hovers. + +## Decision + +A new gate, `scripts/verify-export-jsdoc.ts` (`pnpm run verify-export-jsdoc`, wired into `doc-sync` beside `verify-cordis-catalog`), walks every module-level exported name under each `packages///src/` tree. The parsing and check helpers moved from `gen-cordis-catalog.ts` into a shared `scripts/jsdoc.ts`, so "documented" means the same thing on both surfaces: description prose ends at the first block tag, every checkable parameter needs a non-empty `@param`, a non-void ANNOTATED return needs a non-empty `@returns`, a stale `@param` errors, and violations aggregate into one report. + +The contract by declaration kind: + +- Every exported name needs JSDoc with non-empty description prose. +- Function-like exports (function declarations; consts with function initializers) follow the full function contract. A const whose declarator is type-annotated (`export const f: Handler = …`) defers the return contract to the named type; `@returns` stays optional there. +- Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs. +- Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate). +- Exported namespaces recurse; the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once). +- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. + +Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): + +- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. This is the one question the walk asks the TYPE CHECKER (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). +- **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics. +- **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story. + +`collectExportJsdocViolations()` returns the violation list (the CLI exits 1 on non-empty) so the negative-path tests in `packages/core/agent/tests/verify-export-jsdoc.spec.ts` assert on findings directly, driving fixture packages through every rejection and every exemption. + +## Alternatives considered + +- **eslint-plugin-jsdoc** (`require-jsdoc`/`require-param`/`require-returns`) — covers the mechanical core but cannot express the repo's contract: the heritage-member exemption needs cross-package type resolution, the protocol-slot and namespace-merge idioms are cordis-specific, and the completeness semantics (prose-above-tags, stale-tag errors, aggregate reporting) already have one home in `scripts/jsdoc.ts` shared with the catalog generator. Two subtly different definitions of "documented" is the failure mode this repo's one-home rule exists to prevent. +- **Extending `gen-cordis-catalog.ts`** — the catalog generator renders a curated surface and gates its freshness; a repo-wide walk has no catalog to render. Sharing the helpers while keeping the walks separate keeps each gate's scope legible. +- **Enforcing interface/type-alias member docs** — deferred: it would multiply the checked surface for members that are largely self-describing fields, while the seam classes carrying the load-bearing member contracts are already gated. Revisit if member-doc drift shows up in review. + +## Consequences + +- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green. +- Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them. +- Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements. +- The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets. +- The protocol-slot names are reserved by convention at module top level; a non-protocol export coincidentally named `apply` or `Config` would go unchecked — accepted, documented here. diff --git a/package.json b/package.json index 7453a022e9..662f381711 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", + "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", @@ -48,7 +49,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 489d380787..b39e45c5f5 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -56,6 +56,8 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i * by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash` * builds its request from named fields only and does not forward model input * here (see its README, § "The tool builds its request from named args only"). + * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides. + * @returns the environment to hand to `spawn` for the child process. */ export function childEnv(extra?: Record): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} @@ -147,6 +149,14 @@ export class OutputCollector { private readonly spillDir: string, ) {} + /** + * Ingest one stream chunk, counting it toward the whole-stream total. On + * first overflow of the in-memory cap a spill file is opened and every chunk + * (already-collected ones included) is appended there from then on; the + * in-memory tail then drops whole chunks from its head (or the head of a + * single over-cap chunk) until it fits the cap again. + * @param chunk - the raw bytes from one stream 'data' event. + */ push(chunk: Buffer): void { this.total += chunk.length const overflows = this.bytes + chunk.length > this.maxBytes @@ -190,7 +200,10 @@ export class OutputCollector { // the bottom of this file) and `totalBytes` is read only by a test. The live // background-poll path goes through `readFrom()`, so inline snapshot() into // finalize() and drop or privatize the totalBytes getter. - /** Read the collected tail without finalizing (the final-result snapshot). */ + /** + * Read the collected tail without finalizing (the final-result snapshot). + * @returns the retained tail text, the truncation flag, and the spill path when one was created. + */ snapshot(): CollectedOutput { return { text: Buffer.concat(this.chunks).toString('utf8'), @@ -209,6 +222,8 @@ export class OutputCollector { * pushed since `fromByte`. When `fromByte` has already slid out of the * in-memory tail window, the read is `lossy` — it returns the whole * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created. */ readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } { const windowStart = this.total - this.bytes @@ -223,7 +238,12 @@ export class OutputCollector { } } - /** Close the spill file (if any) and return the final output. */ + /** + * Close the spill file (if any) and return the final output. A failed close + * (delayed writeback fault) stops advertising the spill path — the file may + * be missing its tail — but still returns the in-memory result. + * @returns the final collected output: tail text, truncation flag, and the spill path when intact. + */ finalize(): CollectedOutput { if (this.spillFd !== undefined) { try { @@ -249,6 +269,8 @@ export class OutputCollector { * host process — a kill that cannot be delivered is reported by the process * NOT dying, which callers already handle via escalation/timeouts. No-op for * non-positive pids (spawn never started a process). + * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. + * @param sig - the signal to deliver to the whole group. */ export function killGroup(pid: number, sig: NodeJS.Signals): void { if (pid <= 0) return @@ -290,6 +312,9 @@ export interface RunningBash { * exec sessions addressable via session ids + stdin writes. We deliberately * spawn a fresh non-login `bash -c` per call for determinism (no rc files, * no inherited shell state); revisit when real workflows demand it. + * @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here. + * @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir. + * @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`. */ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { const spillDir = internals.spillDir ?? privateSpillDir() diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 9acd5c7cb7..4715ace318 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -11,7 +11,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand' /** Identifies one background task within an executor (generated `bash-N`). */ export type BashTaskId = Branded<'BashTaskId'> -/** Brand a string as a {@link BashTaskId}. */ +/** + * Brand a string as a {@link BashTaskId}. + * @param id - the raw task-id string (the executor generates `bash-N`). + * @returns the same string, branded; no validation is performed. + */ export function BashTaskId(id: string): BashTaskId { return id as BashTaskId } @@ -26,7 +30,12 @@ export function BashTaskId(id: string): BashTaskId { */ export type OwnerToken = Branded<'OwnerToken'> -/** Brand a string as an {@link OwnerToken}. */ +/** + * Brand a string as an {@link OwnerToken}. Only the consuming boundary + * (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc. + * @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id). + * @returns the same string, branded; no validation is performed. + */ export function OwnerToken(id: string): OwnerToken { return id as OwnerToken } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 478ed70ca5..d4a3105165 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -99,6 +99,8 @@ function streamText(output: CollectedOutput): string { * stderr section, then exit-status markers. Non-zero exits are REPORTED, not * errored — the model decides how to react; only infrastructure failures * (spawn errors, aborts) surface as isError results. + * @param result - the completed foreground run from the executor. + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. */ export function renderResult(result: BashRunResult): string { const out = streamText(result.stdout) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 01fcadbaea..03f7c9ab4b 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -216,6 +216,11 @@ export class BasicCompactService extends CompactService { * Estimate the token count of content blocks — chars divided by the * `charsPerToken` config, with per-block overhead. Override in a subclass to * plug in a real tokenizer. + * + * @param blocks - the blocks to estimate; `tool-result` blocks recurse into + * their nested content, and unknown (merge-extended) types fall back to + * their JSON-stringified length. + * @returns the estimated token count. */ estimateContentTokens(blocks: readonly ContentBlock[]): number { const { charsPerToken } = this.config @@ -246,6 +251,11 @@ export class BasicCompactService extends CompactService { /** * Estimate token count for a single session event. Returns 0 for non-message * event types (boundaries, chunks, usage, errors, compact markers). + * + * @param event - any session event; only the message-bearing types carry + * content to count. + * @returns the estimated token count of the event's content, or 0 for a + * non-message event. */ estimateEventTokens(event: SessionEvent): number { switch (event.type) { @@ -260,7 +270,14 @@ export class BasicCompactService extends CompactService { } } - /** Estimate total tokens across a list of messages plus optional system prompt. */ + /** + * Estimate total tokens across a list of messages plus optional system prompt. + * + * @param messages - the derived conversation messages; each adds a fixed + * role-framing overhead on top of its content estimate. + * @param systemPrompt - counted at chars / `charsPerToken` when provided. + * @returns the estimated token footprint of the whole request. + */ estimateTokens(messages: readonly Message[], systemPrompt?: string): number { let total = 0 for (const msg of messages) { @@ -293,6 +310,13 @@ export class BasicCompactService extends CompactService { * used (`model`, `maxTokens`) — the caller logs the envelope on the * `compact/summary` provenance event, so an overriding subclass (template * or remote summarizer) reports its own envelope honestly. + * + * @param text - plain-text rendering of the conversation region to condense. + * @param agent - supplies the fallback model and the session id stamped on + * the call; throws when neither it nor the config names a model. + * @param signal - optional abort signal, forwarded into the model call. + * @returns the text-only summary blocks plus the call envelope used + * (`model`, and `maxTokens` when the summarizer has a cap). */ async summarize( text: string, agent: Agent, signal?: AbortSignal, diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 2f10084ac3..a590c01431 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -54,6 +54,9 @@ export type ResolvedConfig = Required * each committed summary must be smaller than the content it shadows, and * `compactIfNeeded` may re-compact up to `compactionRetries` extra times before * throwing if the surface still exceeds the threshold. + * + * @param config - the raw, unresolved backend config. + * @returns the validated config with `auto` and `charsPerToken` defaulted. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 433c28326b..49de0f77c4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' * the agent/* event taxonomy — plugins never need this class. */ export class ReactLoopAgent implements Agent { + /** + * The queued + steering FIFOs behind {@link send}/{@link steer}. Public so + * the driver loop can drain it; {@link cancel} clears it wholesale. + */ readonly inbox = new Inbox() private _status: AgentStatus = 'idle' @@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent { * promise (unblocking the idle wait), releases any `whenIdle` waiters, and * aborts the current request if any. The returned `agent.done` promise * resolves once the loop exits. + * @returns the disposer — idempotent and infallible (it runs inside the + * fiber's LIFO disposal chain, where a throw would skip later disposers). */ start(): () => void { this.done = runLoop(this.ctx, this, { diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index a7b2e64e2c..abb588b919 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -24,30 +24,47 @@ export class Inbox { private steeringMessages: InboxMessage[] = [] private wakeup: (() => void) | undefined - /** Resolves when a queued message arrives (used by the idle loop). */ + /** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */ get hasQueued(): boolean { return this.queuedMessages.length > 0 } + /** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */ get hasSteering(): boolean { return this.steeringMessages.length > 0 } + /** + * Add a message to the queued FIFO and wake a parked {@link waitForQueued}. + * @param message - the message to queue for the next turn start. + */ enqueue(message: InboxMessage): void { this.queuedMessages.push(message) this.wakeup?.() } + /** + * Add a message to the steering FIFO. Deliberately no wakeup: steering is + * drained between steps of a running turn, never by the idle wait — + * `Agent.steer()` on an idle agent falls back to `send()` instead. + * @param message - the message to inject between steps of the running turn. + */ steer(message: InboxMessage): void { this.steeringMessages.push(message) } - /** Drain all queued messages (turn start). */ + /** + * Drain all queued messages (turn start). + * @returns the drained messages in arrival order; the queued FIFO is left empty. + */ drainQueued(): InboxMessage[] { return this.queuedMessages.splice(0) } - /** Drain all steering messages (between steps). */ + /** + * Drain all steering messages (between steps). + * @returns the drained messages in arrival order; the steering FIFO is left empty. + */ drainSteering(): InboxMessage[] { return this.steeringMessages.splice(0) } @@ -62,7 +79,12 @@ export class Inbox { this.steeringMessages.length = 0 } - /** Wait until a queued message arrives or `cancel` resolves. */ + /** + * Wait until a queued message arrives or `cancel` resolves. + * @param cancel - a promise whose resolution abandons the wait without a + * message (the driver loop passes the agent's disposed promise so a parked + * loop can exit). + */ waitForQueued(cancel: Promise): Promise { if (this.hasQueued) return Promise.resolve() const { promise, resolve } = Promise.withResolvers() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9eaa61624a..55620650c1 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -29,6 +29,10 @@ declare module 'cordis' { } } +/** + * Plugin config: the agents to create — or resume, via `resumeSessionId` — + * declaratively at startup, so a cordis.yml deployment needs no code. + */ export interface Config { /** Agents created from configuration at startup. */ agents: (AgentOptions & { diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 9484f0ad5f..eb5b07ba23 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -185,6 +185,9 @@ export interface LoopHandle { * re-enqueue leftover steering as queued ⟵ steering is never stranded * idle (emit agent/status) unless more queued * ``` + * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. + * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). + * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads. */ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { // Per-instance transmission bookkeeping: whether THIS loop instance has @@ -875,7 +878,11 @@ function withoutToolCalls(message: Message): Message { return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } } -/** The last turn number in a (possibly seeded) session log, or 0. */ +/** + * The last turn number in a (possibly seeded) session log, or 0. + * @param session - the session whose log is scanned for the latest `turn/start`. + * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one). + */ export function lastTurnNumber(session: Session): number { const lastStart = session.events.findLast(event => event.type === 'turn/start') return lastStart?.data.turn ?? 0 @@ -889,6 +896,8 @@ export function lastTurnNumber(session: Session): number { * returns to idle), so status is not a reliable open-turn signal. Used by * `inject()` to choose between appending into an open turn vs. wrapping the * injection in its own one-shot turn (the turn-enclosure RFC). + * @param session - the session whose log is inspected. + * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet. */ export function isTurnOpen(session: Session): boolean { const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end') diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index 07c57b9ff9..90f47068fa 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -19,7 +19,10 @@ export interface TransmissionLog { loggedHeader: boolean } -/** Fresh bookkeeping for a newly-started loop instance. */ +/** + * Fresh bookkeeping for a newly-started loop instance. + * @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot. + */ export function createTransmissionLog(): TransmissionLog { return { loggedHeader: false } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1fd6a57867..46bc52128a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -50,7 +50,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> -/** Brand a string as an {@link AgentId}. */ +/** + * Brand a string as an {@link AgentId}. + * @param id - the raw agent id string. + * @returns the same string, branded (a compile-time cast — no runtime cost). + */ export function AgentId(id: string): AgentId { return id as AgentId } @@ -80,10 +84,22 @@ export interface AgentOptions { model?: string } +/** + * Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An + * absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content + * must label itself here or its message is recorded as a user prompt (see + * {@link HookContext} on why that label is load-bearing). + */ export interface SendOptions { source?: MessageSource } +/** + * An agent's lifecycle state, emitted on every transition as `agent/status`: + * `idle` (parked, waiting for queued work), `running` (a turn is in progress), + * `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject` + * throw). + */ export type AgentStatus = 'idle' | 'running' | 'disposed' /** diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts new file mode 100644 index 0000000000..89439a3a59 --- /dev/null +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -0,0 +1,296 @@ +/** + * Negative-path tests for the export-surface JSDoc gate + * (`scripts/verify-export-jsdoc.ts`). + * + * The gate's positive half runs against the real tree in CI (`pnpm run + * verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that + * the walk REJECTS an undocumented surface the way it promises to — and that + * every deliberate exemption (heritage members, plugin-protocol slots, + * constructors, overload implementations, augmentation bodies, re-exports) + * actually holds. These tests drive `collectExportJsdocViolations()` against + * synthetic fixture packages, mirroring the gen-cordis-catalog negative + * tests. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts' + +const roots: string[] = [] + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) +}) + +/** Write fixture files under `packages/group/fix/src/` and return the scan root. */ +function fixture(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-')) + roots.push(root) + for (const [rel, content] of Object.entries(files)) { + const abs = join(root, 'packages', 'group', 'fix', 'src', rel) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content) + } + return root +} + +/** Single-file fixture shorthand: the content becomes `src/index.ts`. */ +const make = (content: string): string => fixture({ 'index.ts': content }) + +describe('verify-export-jsdoc functions and consts', () => { + it('accepts a fully documented surface', () => { + expect(collectExportJsdocViolations(make(` +/** + * Add one to a count. + * @param n - the count to bump. + * @returns the count plus one. + */ +export function bump(n: number): number { return n + 1 } + +/** + * Fire-and-forget (void needs no @returns). + * @param flag - whether to arm. + */ +export function poke(flag: boolean): void { void flag } + +/** The default retry budget. */ +export const RETRIES = 3 + +/** + * Halve a count. + * @param n - the count to halve. + * @returns the count halved. + */ +export const halve = (n: number): number => n / 2 +`))).toEqual([]) + }) + + it('flags an exported function with no JSDoc at all', () => { + expect(collectExportJsdocViolations(make( + 'export function bare(): void {}\n', + ))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)]) + }) + + it('flags a missing @param and a missing @returns', () => { + const violations = collectExportJsdocViolations(make( + '/** Docs without tags. */\nexport function f(x: number): number { return x }\n', + )) + expect(violations).toEqual([ + expect.stringMatching(/exported function 'f' .* is missing @param x\./), + expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./), + ]) + }) + + it('flags an unannotated (inferred) return type', () => { + expect(collectExportJsdocViolations(make( + '/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n', + ))).toEqual([expect.stringMatching(/no return type annotation/)]) + }) + + it('flags tags-only JSDoc with no description prose', () => { + expect(collectExportJsdocViolations(make( + '/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n', + ))).toEqual([expect.stringMatching(/no description prose above its block tags/)]) + }) + + it('flags a stale @param and a binding-pattern parameter', () => { + const violations = collectExportJsdocViolations(make( + '/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n', + )) + expect(violations).toEqual([ + expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/), + expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/), + ]) + }) + + it('exempts a `this` receiver annotation from @param', () => { + expect(collectExportJsdocViolations(make( + '/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n', + ))).toEqual([]) + }) + + it('waives @returns for a declarator-annotated const but not an unannotated one', () => { + expect(collectExportJsdocViolations(make(` +type Fn = (x: number) => number +/** + * Uses the named signature. + * @param x - value. + */ +export const good: Fn = x => x +/** + * No signature anywhere. + * @param x - value. + */ +export const bad = (x: number) => x +`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)]) + }) + + it('requires description prose on a non-function const', () => { + expect(collectExportJsdocViolations(make( + 'export const LIMIT = 10\n', + ))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)]) + }) +}) + +describe('verify-export-jsdoc type-level exports', () => { + it('requires description prose on interfaces, type aliases, and enums', () => { + const violations = collectExportJsdocViolations(make( + 'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n', + )) + expect(violations).toEqual([ + expect.stringMatching(/exported interface 'I' .* has no JSDoc\./), + expect.stringMatching(/exported type 'T' .* has no JSDoc\./), + expect.stringMatching(/exported enum 'E' .* has no JSDoc\./), + ]) + }) + + it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => { + expect(collectExportJsdocViolations(make( + "declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n", + ))).toEqual([]) + }) +}) + +describe('verify-export-jsdoc export forms', () => { + it('resolves an `export { … }` list to the local declaration', () => { + expect(collectExportJsdocViolations(make( + 'function f(): void {}\nexport { f }\n', + ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)]) + }) + + it('reports a re-exported module once, at its defining file', () => { + const violations = collectExportJsdocViolations(fixture({ + 'index.ts': "export * from './other.ts'\n", + 'other.ts': 'export function f(): void {}\n', + })) + expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)]) + }) + + it('exempts overload implementations when the signatures are documented', () => { + expect(collectExportJsdocViolations(make(` +/** + * From a number. + * @param x - the number. + * @returns its text. + */ +export function f(x: number): string +/** + * From a flag. + * @param x - the flag. + * @returns its text. + */ +export function f(x: boolean): string +export function f(x: number | boolean): string { return String(x) } +`))).toEqual([]) + }) +}) + +describe('verify-export-jsdoc classes', () => { + it('flags an undocumented class, method, property, and accessor', () => { + const violations = collectExportJsdocViolations(make(` +export class C { + state = 1 + get view(): number { return this.state } + run(x: number): number { return x } +} +`)) + expect(violations).toEqual([ + expect.stringMatching(/exported class 'C' .* has no JSDoc\./), + expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./), + expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./), + expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./), + ]) + }) + + it('exempts members declared by an extends/implements heritage type', () => { + expect(collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** + * Do it. + * @param x - input. + * @returns output. + */ + abstract run(x: number): number +} +/** Iface. */ +export interface Sized { + /** Byte size. */ + size: number +} +/** Impl. */ +export class Impl extends Base implements Sized { + size = 0 + run(x: number): number { return x } +} +`))).toEqual([]) + }) + + it('skips private/protected/#private members and constructors', () => { + expect(collectExportJsdocViolations(make(` +/** Documented. */ +export class C { + #secret = 1 + private hidden(): void {} + protected hook(): void {} + constructor(x: number) { void x } +} +`))).toEqual([]) + }) + + it('exempts plugin-protocol statics but checks other statics', () => { + const violations = collectExportJsdocViolations(make(` +/** Plugin. */ +export class C { + static Config = { a: 1 } + static inject = ['bash'] + static reusable = true + static other = 1 +} +`)) + expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)]) + }) + + it("covers a set accessor by the getter's doc", () => { + expect(collectExportJsdocViolations(make(` +/** Documented. */ +export class C { + /** The current width. */ + get width(): number { return 1 } + set width(_v: number) {} +} +`))).toEqual([]) + }) +}) + +describe('verify-export-jsdoc plugin protocol and namespaces', () => { + it('exempts top-level plugin-protocol exports', () => { + expect(collectExportJsdocViolations(make(` +export const name = 'fix' +export const inject = ['bash'] +export const reusable = true +export const Config = { parse: true } +export function apply(): void {} +`))).toEqual([]) + }) + + it('recurses into namespaces with qualified names and honors the merge idiom', () => { + const violations = collectExportJsdocViolations(make(` +/** The plugin class. */ +export class Fix {} +export namespace Fix { + export interface Config { a: number } +} +export namespace Loose { + export const x = 1 +} +`)) + expect(violations).toEqual([ + expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./), + expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./), + expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./), + ]) + }) +}) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 18c7b4a8a2..79a1c10a1c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -153,10 +153,15 @@ export class Session { this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } } + /** + * The append-only event log, exposed live by reference (readonly-typed, not + * a snapshot): later appends are visible through the same array. + */ get events(): readonly SessionEvent[] { return this.log } + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number { return this.log.length } @@ -175,6 +180,9 @@ export class Session { * declare how it joins the surface, the sole source of derived history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. * @throws if `data` is not losslessly JSON-serializable (BigInt, function, * symbol, undefined, non-finite number, circular ref, or an exotic object * like Map/Set/Date). The event log is the durable source of truth, so this diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 47197b7b90..22303c6c61 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: * hiding under a symbol/non-enumerable key cannot make the round-trip lossy. * Getters are invoked during the check (again as `JSON.stringify` would), so the * contract is for plain data records, not objects with side-effecting accessors. + * @param value - the candidate event data to test. + * @param seen - objects on the current descent path, for circular-reference + * detection; the recursion threads it — callers omit it. + * @returns true when `value` survives a JSON round-trip losslessly. */ export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { if (value === null) return true diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index b894ef26cb..cb780da013 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts' * Only the LAST turn can be open: the invariants plugin guarantees a `turn/end` * before any later `turn/start`, so an interior open turn is impossible in a * valid committed log. Likewise at most one step is open within that turn. + * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail). + * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced. */ export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] { let openTurn: number | null = null diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 37843970a2..7219856bdb 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set([ * surface-eligible event that is MISSING its mandatory marker (e.g. validating * a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed * {@link SurfaceEvent} with `surfaceOp` present. + * @param type - the event type string to test. + * @returns true when the type is one of the five message-producing types. */ export function isSurfaceEligibleType(type: string): boolean { return SURFACE_EVENT_TYPES.has(type) @@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean { * Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the * event's `type` is surface-eligible AND that `surfaceOp` is present. * The narrowed type has mandatory {@link SurfaceOp}. + * @param event - the event to narrow. + * @returns true when the event is surface-eligible and carries its `surfaceOp` marker. */ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent { if (!SURFACE_EVENT_TYPES.has(event.type)) return false diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 638daaf654..6ea3042bd7 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number { * surface successor (`SurfaceNode.next`), or `null` when `end` is the tail — * for the cut after `end`. * + * @param nodes - the surface linked list in head→tail order. + * @param events - the session log each node's `seq` indexes into. + * @param beforeSeq - names the cut (the node it sits immediately before); + * `null` — or any seq not on the surface — means the after-tail cut. + * @returns true when every `tool-call` before the cut is answered before it + * (the unanswered-call depth at the cut is zero). * @throws if the surface prefix drives the unanswered-call depth negative — a * `tool/result` with no preceding open `tool-call` on the surface. That is a * corrupt surface (a structural invariant violation), surfaced loudly here diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ec227e9884..e2e0e1b216 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -4,7 +4,11 @@ import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, T /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> -/** Brand a string as a {@link SessionId}. */ +/** + * Brand a string as a {@link SessionId}. + * @param id - the raw session id string. + * @returns the same string, branded (a compile-time cast — no runtime cost). + */ export function SessionId(id: string): SessionId { return id as SessionId } @@ -102,6 +106,7 @@ export interface TurnTriggerMap { injection: { kind: 'injection'; source: MessageSource } } +/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] /** @@ -156,6 +161,7 @@ export interface TurnEndReasonMap { interrupted: { kind: 'interrupted' } } +/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap] /** @@ -361,6 +367,7 @@ export interface SessionEventMap { 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } } +/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ export type SessionEventType = keyof SessionEventMap /** diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c970c66b30..049b9c3b99 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -110,6 +110,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** A complete `{{...}}` reference group at the scan position (validated after). */ const GROUP_AT = /^\{\{([^{}]*)\}\}/ +/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { /** * The deployment's persona — the ONE deployment-authored fragment of the @@ -138,6 +139,10 @@ export interface Config { * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A * lone `{{` with no `}}` anywhere after it is ordinary prose and passes * through verbatim. Substituted values are never re-scanned. + * @param assembly - the assembly to render (typically the awaited result of + * {@link SystemPrompt.assemble}); only `sections` and `variables` are read. + * @returns the full system prompt text; `''` when every section renders empty + * (the caller then sends no system prompt at all). */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 9149241225..16eff5c324 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -155,6 +155,9 @@ export interface JsonSchemaObject { * `properties`, `required` array). * * This is a plain function — no schemastery or other framework dependency. + * @param spec - the author-facing per-property schema to convert. + * @returns the wire-format JSON Schema; the top-level `required` array is + * omitted entirely when no property is marked required. */ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject { const properties: Record = {} @@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] { * keys are allowed (no `additionalProperties: false`); `default` is not * applied; an `object`/`array` prop without `properties`/`items` only * type-checks; `enum` is membership (strings only). + * @param spec - the declared parameter schema to validate against. + * @param args - the model-generated arguments, however malformed. + * @returns the violation messages in declaration order; empty means valid. */ export function validateArgs(spec: SchemaSpec, args: unknown): string[] { return checkSpec(spec, args, '') @@ -340,6 +346,13 @@ export interface DefineToolOptions { * Raw JSON-Schema tool definitions (from MCP servers) are still accepted * by `ToolRegistry.register()` directly — `defineTool` is sugar for * first-party plugin authors. + * @param options - the tool's name, description, typed parameter schema, + * execute body, and optional presenters. + * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the + * raw args first (throwing {@link ToolArgsError} on mismatch, which the + * registry turns into an isError result), and its presenters validate softly + * (returning undefined on mismatch, since replay may feed them older-schema + * args). */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b1b9c25397..f9af5375d2 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -129,6 +129,9 @@ export interface LocalDirEntry { * and intermediate directories are created by the write. Two input paths * reaching the same file via symlinks share one key. Falls back to the absolute * path only when no ancestor (not even the filesystem root) can be resolved. + * @param cwd - base directory a relative `path` resolves against. + * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`. + * @returns the absolute display path plus the realpath-derived stable target key. */ export async function resolveLocalTarget(cwd: string, path: string): Promise { if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') @@ -165,7 +168,11 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { try { const info = await stat(absolutePath) @@ -200,6 +207,9 @@ async function resolveListedChildTarget(parent: LocalTarget, name: string): Prom * List direct children of a directory in stable name order. Each child includes * a resolved target plus stat metadata when still available; file contents are * never read. + * @param target - the resolved directory to list; a missing or non-directory target throws. + * @param signal - aborts the listing, checked between children (`FS_ABORTED`). + * @returns one entry per direct child, sorted by name. */ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise { throwIfAborted(signal, 'list') @@ -290,6 +300,9 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort /** * Read a whole regular UTF-8 text file into a single decoded string. Rejects * non-regular files, invalid UTF-8, and NUL-byte binary samples. + * @param target - the resolved file to read. + * @param signal - aborts the read (`FS_ABORTED`). + * @returns the full decoded text, byte-for-byte (no normalization). */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { await statRegularFile(target, 'read', signal) @@ -305,6 +318,9 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): * Stream a whole regular UTF-8 text file as decoded text chunks. Same text * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection, * cross-chunk UTF-8 decoding), but never holds the whole file in memory. + * @param target - the resolved file to stream. + * @param signal - aborts the stream, including between chunks (`FS_ABORTED`). + * @returns decoded text chunks in file order; chunk boundaries carry no meaning. */ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable { await statRegularFile(target, 'read', signal) @@ -352,6 +368,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow * (`0o700`) staging directory, fsync, optionally chmod to the final mode while * still private, then rename over the target. `mode` (when given) preserves an * existing file's permissions across the replace. + * @param absolutePath - the final destination (typically a target key); missing parent dirs are created. + * @param content - the full UTF-8 text to write. + * @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`. + * @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn. + * @param internals - test seam for pinning temp names and observing the staged file. */ export async function writeFileAtomic( absolutePath: string, @@ -409,6 +430,12 @@ export async function writeFileAtomic( /** Line ending style detected before LF normalization. */ export type LineEndings = 'LF' | 'CRLF' +/** + * Collapse CRLF to LF — the canonical in-memory form every edit/diff basis + * uses. Lone `\r` bytes (not followed by `\n`) are left untouched. + * @param content - decoded text in whatever line-ending style the file had. + * @returns the text with every `\r\n` pair replaced by `\n`. + */ function normalizeLineEndings(content: string): string { return content.replaceAll('\r\n', '\n') } @@ -420,6 +447,14 @@ function detectLineEndings(raw: string): LineEndings { return crlfCount > lfCount ? 'CRLF' : 'LF' } +/** + * Convert LF-normalized content back to the line-ending style detected at read + * time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes + * first so an already-CRLF sequence is never doubled to `\r\r\n`. + * @param content - the LF-normalized (edited) text. + * @param lineEndings - the original file's style, as detected by {@link readForEdit}. + * @returns the text in the original file's line-ending style. + */ function restoreLineEndings(content: string, lineEndings: LineEndings): string { return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n') } @@ -438,6 +473,10 @@ function countOccurrences(content: string, needle: string): number { /** * Read and decode a file for editing: rejects binaries, returns LF-normalized * content plus the original line-ending style for write-back. + * @param absolutePath - the file to read (typically a target key). + * @param displayPath - the caller-facing path used in error messages. + * @param signal - aborts the read (`FS_ABORTED`). + * @returns the LF-normalized content and the detected style to restore on write-back. */ export async function readForEdit( absolutePath: string, @@ -459,6 +498,9 @@ export async function readForEdit( * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis * (the caller treats `null` the same as an absent file: the result renders a * whole-file diff rather than an applied hunk). + * @param absolutePath - the file to read (typically a target key); it must exist. + * @param signal - aborts the read (`FS_ABORTED`). + * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. */ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { const buffer = await readFileAbortable(absolutePath, 'read', signal) @@ -477,6 +519,12 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and * `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns * the edited content (still LF-normalized) and the replacement count. + * @param content - the current file content, already LF-normalized. + * @param oldString - literal text to find; CRLF inside it is normalized to LF before matching. + * @param newString - literal replacement text, normalized the same way. + * @param replaceAll - replace every match instead of requiring exactly one. + * @param displayPath - the caller-facing path used in error messages. + * @returns the edited LF-normalized content plus how many occurrences were replaced. */ export function applyLiteralEdit( content: string, diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 0ad8365d4a..1a3ebc57f6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -73,6 +73,7 @@ export class LocalFileSystem extends FileSystem { cwd: z.string().default(process.cwd()), }) + /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig /** Test seam forwarded to fsio (force streaming path, pin temp names). */ internals: FsIoInternals = {} diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 9351a373db..f6f5b8005f 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -29,7 +29,12 @@ import type { Branded } from '@deepseek-ai/dsh-brand' */ export type FsTargetKey = Branded<'FsTargetKey'> -/** Brand a string as an {@link FsTargetKey}. */ +/** + * Brand a string as an {@link FsTargetKey}. For backend use only — a consumer + * never manufactures a key, it receives one from `resolve()`. + * @param key - the backend's raw key string (the local backend passes a realpath). + * @returns the same string, branded; no validation is performed. + */ export function FsTargetKey(key: string): FsTargetKey { return key as FsTargetKey } @@ -42,7 +47,12 @@ export function FsTargetKey(key: string): FsTargetKey { */ export type FsVersion = Branded<'FsVersion'> -/** Brand a string as an {@link FsVersion}. */ +/** + * Brand a string as an {@link FsVersion}. For backend use only — a consumer + * never manufactures a version, it receives one from `stat`/write/edit outcomes. + * @param v - the backend's raw version string (the local backend derives it from mtime+size). + * @returns the same string, branded; no validation is performed. + */ export function FsVersion(v: string): FsVersion { return v as FsVersion } diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index a45082489a..da8b7a872e 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -40,6 +40,10 @@ export type FsDiffMeta = { diffs: FileDiff[] } * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring * the call-time card's new-file convention. The unified-diff "\ No newline at end * of file" markers are dropped — they annotate the patch, not file content. + * @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it). + * @param before - the file text before the change (the backend's LF-normalized diff basis). + * @param after - the file text after the change, on the same basis. + * @returns one diff per applied hunk, in file order; empty when the texts are identical. */ export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] { const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT }) @@ -83,6 +87,8 @@ function isFileDiff(value: unknown): value is FileDiff { * it validates defensively rather than trusting the payload — a bad `meta` yields * `undefined`, and the caller decides the fallback (edit → the generic result * rendering; write → an args-derived whole-file diff), never a thrown presenter. + * @param meta - the opaque `tool/result` meta payload (live or replayed from the session log). + * @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload. */ export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 5ede545b03..220c850d69 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -29,7 +29,13 @@ interface EditInput { replaceAll: boolean } -/** Validate value constraints the schema DSL can't express. */ +/** + * Validate value constraints the schema DSL can't express: a non-blank + * `file_path`, a non-empty `old_string`, and `old_string !== new_string` + * (an equal pair would be a guaranteed no-op edit). + * @param args - the schema-validated raw tool arguments. + * @returns the camelCased input with `replace_all` defaulted to false. + */ export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string') @@ -42,14 +48,22 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new } } -/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */ +/** + * Format an edit success (single-match or replace-all) as a Claude-style model-facing message. + * @param displayPath - the backend-resolved path shown to the model. + * @param replaceAll - selects the all-occurrences wording over the single-replacement one. + * @returns the confirmation sentence the model sees as the tool result. + */ export function formatEditOutput(displayPath: string, replaceAll: boolean): string { return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.` } -/** Register the `edit` tool and its system-prompt guidance. */ +/** + * Register the `edit` tool and its system-prompt guidance. + * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + */ export function applyEditTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:edit', diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index c54279baa9..8cafc0d400 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -119,6 +119,10 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string * path serves both. Scans for newlines with a capped line buffer (a newline-free * giant line is truncated, never buffered past `request.maxLineLength`), * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. + * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning. + * @param request - the resolved window; the caller has already applied its defaults and caps. + * @param displayPath - the caller-facing path used in the offset-out-of-range error. + * @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag. */ export async function buildWindow( chunks: AsyncIterable | Iterable, @@ -156,7 +160,12 @@ export async function buildWindow( return finish(acc, request, displayPath) } -/** Format a read outcome as one OpenCode-style line-numbered text block body. */ +/** + * Format a read outcome as one OpenCode-style line-numbered text block body. + * @param displayPath - the backend-resolved path rendered in the envelope's `` element. + * @param outcome - the windowed read to render. + * @returns the model-facing envelope: numbered lines plus a continuation or end-of-file footer. + */ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string { const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1) let footer: string diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index a9cc4a9806..039d8742e9 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -58,7 +58,12 @@ function parsePositiveInteger(value: number, name: string): number { return value } -/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */ +/** + * Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. + * @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given. + * @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted. + * @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`. + */ export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') @@ -67,7 +72,11 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? return { filePath: args.file_path, offset, limit } } -/** Register the `read` tool and its system-prompt guidance. */ +/** + * Register the `read` tool and its system-prompt guidance. + * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + * @param caps - the deployment's resolved read caps (plugin config after defaulting). + */ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.systemPrompt.section({ name: 'tool:read', diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index b7774fb201..16ba17e2e5 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -18,7 +18,11 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools' -/** The session workspace cwd for this call, or `undefined` when none applies. */ +/** + * The session workspace cwd for this call, or `undefined` when none applies. + * @param exec - the tool-execution context; only its optional `agent` is read. + * @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default). + */ export function sessionCwd(exec: ToolExecution): string | undefined { return exec.agent?.session.header.cwd } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 1054e2ff2f..fd4eec45f3 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -21,13 +21,23 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' -/** Validate value constraints the schema DSL can't express. */ +/** + * Validate value constraints the schema DSL can't express: only a non-blank + * `file_path` — an empty `content` is legitimate (it writes an empty file). + * @param args - the schema-validated raw tool arguments. + * @returns the camelCased input; `content` passes through untouched. + */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') return { filePath: args.file_path, content: args.content } } -/** Format a write outcome as one model-facing text block body. */ +/** + * Format a write outcome as one model-facing text block body. + * @param displayPath - the backend-resolved path rendered in the envelope's `` element. + * @param outcome - the write outcome; its `operation` selects the Created/Updated wording. + * @returns the model-facing confirmation envelope (no file content is echoed back). + */ export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string { const verb = outcome.operation === 'create' ? 'Created' : 'Updated' return `${displayPath} @@ -37,7 +47,10 @@ ${verb} file ` } -/** Register the `write` tool and its system-prompt guidance. */ +/** + * Register the `write` tool and its system-prompt guidance. + * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + */ export function applyWriteTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts index 7cf97feca9..59de886e5e 100644 --- a/packages/hooks/hook-protocol/src/codec.ts +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -75,6 +75,12 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision'] * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`) * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the * block as-is — a caller that doesn't key by event opts out of the check. + * + * @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all. + * @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit. + * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2. + * @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is. + * @returns the dialect-neutral decoded outcome. */ export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput { const trimmedErr = stderr.trim() diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index 5e14f964fb..df6a10672a 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -66,6 +66,9 @@ export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500 * `undefined` when empty, cut at `maxChars` with an ellipsis when over. The * bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns * the config default and passes it in. + * @param stderr - the hook's raw captured stderr. + * @param maxChars - the character cap for the summary (the bridge's config value). + * @returns the trimmed, capped summary, or `undefined` when stderr is blank. */ export function summarizeStderr(stderr: string, maxChars: number): string | undefined { const t = stderr.trim() @@ -73,7 +76,11 @@ export function summarizeStderr(stderr: string, maxChars: number): string | unde return t.length > maxChars ? t.slice(0, maxChars) + '…' : t } -/** Append a `hook/invoked` provenance event to `session`. */ +/** + * Append a `hook/invoked` provenance event to `session`. + * @param session - the session whose open turn records the event. + * @param invocation - the invocation identity; an absent `matcher` is omitted from the payload. + */ export function appendHookInvoked(session: Session, invocation: HookInvocation): void { session.append('hook/invoked', { turn: invocation.turn, @@ -91,6 +98,8 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode` * is omitted when the hook never ran. + * @param session - the session whose open turn records the event. + * @param record - the outcome to record: the decoded output plus the summary cap and duration. */ export function appendHookResult(session: Session, record: HookResultRecord): void { const { output } = record diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index ee1dd324b3..6863ee999f 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -34,6 +34,10 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ * pattern exact-matches the query (splitting `|` into alternatives); every other * `claude` pattern and ALL `codex` patterns are tested as an unanchored regex. * An invalid regex matches nothing (never throws). + * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. + * @param query - the candidate value (a tool name, a session source, …). + * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. + * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { if (isMatchAll(matcher)) return true diff --git a/packages/hooks/hook-protocol/src/merge.ts b/packages/hooks/hook-protocol/src/merge.ts index 1e53dbaaea..d219c4eb18 100644 --- a/packages/hooks/hook-protocol/src/merge.ts +++ b/packages/hooks/hook-protocol/src/merge.ts @@ -71,6 +71,8 @@ function decisionForRank(maxRank: number): MergedDecision { * into one {@link MergedHookOutcome} by the precedence rules above. An empty list * yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the * caller treats that as "no hook had anything to say". + * @param outputs - every matched hook's decoded output, in hook order. + * @returns the single folded outcome the bridge maps onto its seam. */ export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome { let maxRank = 0 diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index f5a892c468..e4a2bc8a04 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -70,6 +70,11 @@ export interface RunHookResult { * `exitCode: undefined`, so the caller's merge logic treats it as a * non-blocking error rather than crashing the turn. `now` is injected for * testable durations. + * @param bash - the executor seam the command runs through. + * @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout. + * @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout. + * @param now - millisecond clock used for the reported duration. + * @returns the decoded output plus the run's wall-clock duration. */ export async function runHook( bash: BashExecutor, diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index d78486e58a..b447c28533 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -43,7 +43,12 @@ function asObject(value: unknown): Record | undefined { : undefined } -/** Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. */ +/** + * Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. + * @param command - the raw command from config. + * @param vars - the substitution values; a token whose variable is unset stays verbatim. + * @returns the command with every occurrence of each set token replaced. + */ export function substituteCommand(command: string, vars: SubstitutionVars): string { let out = command if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot) @@ -57,6 +62,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * Non-command hooks and malformed entries are dropped (recorded in `skipped` / * silently ignored) rather than throwing — a bad hook config must not crash boot. * `vars` are substituted into every surviving `command`. + * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map. + * @param vars - substitution values applied to every surviving `command` (defaults to none). + * @returns the runnable per-event groups plus the skipped non-command hooks. */ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig { const config: ClaudeHookConfig = {} diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 411f058eea..505f8d2bda 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -41,6 +41,8 @@ function asObject(value: unknown): Record | undefined { * `type !== 'command'` and `async: true` command hooks are skipped (recorded in * `skipped`). Malformed entries are ignored rather than thrown — a bad config * must not crash boot. No command substitution (Codex does none). + * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. + * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { const config: CodexHookConfig = {} diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 9e5e5179b6..e0627c5695 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -13,7 +13,9 @@ import { parseSse } from './sse.ts' import { translate } from './translate.ts' import type { WireError } from './types.ts' +/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ export interface DeepSeekAdapterOptions { + /** Bearer token sent in the `authorization` header on every request. */ apiKey: string /** Endpoint base; `/chat/completions` is appended. */ baseURL: string @@ -21,7 +23,11 @@ export interface DeepSeekAdapterOptions { defaults?: RequestDefaults } -/** Map an HTTP status to a stable LlmError code. */ +/** + * Map an HTTP status to a stable LlmError code. + * @param status - status of a non-2xx provider response. + * @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_` for anything else. + */ export function httpErrorCode(status: number): string { if (status === 401 || status === 403) return 'AUTH' if (status === 429) return 'RATE_LIMIT' diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 79313f910f..3a3d7bb4a1 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -34,6 +34,12 @@ export type * from './types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] +/** + * Plugin config, validated by the same-named schemastery schema. Every field + * is optional in yml: credentials/endpoint fall back to the environment (a + * missing API key fails plugin load, not the first call), and omitted + * thinking fields send nothing on the wire, so the provider default applies. + */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ apiKey?: string diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bbca37223f..10ef905d7e 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -66,6 +66,8 @@ function serializeAssistant(message: Message): WireMessage { * `{role: 'tool'}` messages; the harness puts each tool result in its own * user-role message, so a mixed user message contributes its text first and * its tool results as separate wire messages after. + * @param messages - the harness conversation, in order. + * @returns the wire messages; order preserved, each tool result expanded into its own entry. */ export function serializeMessages(messages: Message[]): WireMessage[] { const wire: WireMessage[] = [] @@ -97,7 +99,14 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } -/** Build the full wire request. */ +/** + * Build the full wire request. Always streaming (`stream: true`, usage + * reporting on); optional fields are omitted rather than sent as null, so + * provider defaults apply. + * @param options - the harness request (model, history, system, tools, sampling). + * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. + * @returns the chat-completions request body. + */ export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest { const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index 252870471a..6856289aec 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -37,6 +37,8 @@ function eventData(block: string): string | undefined { * Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). + * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. + * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ export async function* parseSse(stream: AsyncIterable): AsyncGenerator { const decoder = new TextDecoder() diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index 08cc019b61..cd6bc8f108 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -29,7 +29,11 @@ interface OpenBlock { name?: string } -/** Map the wire finish_reason vocabulary to the harness FinishReason. */ +/** + * Map the wire finish_reason vocabulary to the harness FinishReason. + * @param reason - the wire `finish_reason` string. + * @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`. + */ export function mapFinishReason(reason: string): FinishReason { switch (reason) { case 'stop': return { kind: 'stop' } @@ -46,6 +50,8 @@ export function mapFinishReason(reason: string): FinishReason { * (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`, * api/create-chat-completion); the harness TokenUsage convention is * DISJOINT counts, so cache reads are subtracted out of `inputTokens`. + * @param usage - wire usage from the finish chunk or the trailing usage-only chunk. + * @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them. */ export function mapUsage(usage: WireUsage): TokenUsage { const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens @@ -75,6 +81,8 @@ function closeBlock(block: OpenBlock): ContentBlock { /** * Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks. * Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`. + * @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated. + * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel. */ export async function* translate(payloads: AsyncIterable): AsyncGenerator { let nextIndex = 0 diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 072c43babb..a9d7403a35 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -48,12 +48,18 @@ export interface WireToolMessage { content: string } +/** One entry of the request `messages` array, discriminated on `role`. */ export type WireMessage = | WireSystemMessage | WireUserMessage | WireAssistantMessage | WireToolMessage +/** + * Assistant-role history message. The harness replays `content: ""` (never + * null) on tool-call-only turns — some gateways reject null — and sends null + * only when the turn carried neither text nor tool calls. + */ export interface WireAssistantMessage { role: 'assistant' content: string | null @@ -66,12 +72,14 @@ export interface WireAssistantMessage { tool_calls?: WireToolCall[] } +/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */ export interface WireToolCall { id: string type: 'function' function: { name: string; arguments: string } } +/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */ export interface WireTool { type: 'function' function: { @@ -88,11 +96,13 @@ export interface WireChunk { usage?: WireUsage | null } +/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */ export interface WireChoice { delta?: WireDelta finish_reason?: string | null } +/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */ export interface WireDelta { role?: string /** Visible text. Null/empty on reasoning/tool-call chunks. */ @@ -105,6 +115,7 @@ export interface WireDelta { tool_calls?: WireToolCallDelta[] } +/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */ export interface WireToolCallDelta { /** Disambiguates parallel tool calls; stable across a call's deltas. */ index: number @@ -119,6 +130,13 @@ export interface WireToolCallDelta { } } +/** + * Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals + * `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts + * them to keep the harness convention of disjoint counts. + * `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the + * hit count. + */ export interface WireUsage { prompt_tokens: number completion_tokens: number diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index a24e335d8b..00107e8e1c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -21,14 +21,22 @@ import { toPiContext, toStreamChunks } from './convert.ts' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ export type PiAiReasoning = 'off' | 'high' | 'xhigh' +/** Constructor options for {@link PiAiAdapter}; the plugin's `apply` resolves them from Config + environment. */ export interface PiAiAdapterOptions { + /** Bearer token pi-ai sends on every request. */ apiKey: string + /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** Thinking level applied to every request ('off' disables thinking). */ reasoning?: PiAiReasoning | undefined } -/** Build the inline pi-ai model descriptor for one DeepSeek model name. */ +/** + * Build the inline pi-ai model descriptor for one DeepSeek model name. + * @param modelId - harness model name; sent verbatim on the wire. + * @param options - adapter options; only `baseURL` is read here (key and reasoning apply per request, not per descriptor). + * @returns a descriptor with every DeepSeek compat flag explicit — pi-ai's URL-based auto-detection is never relied on. + */ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<'openai-completions'> { return { id: modelId, diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 6fa96a0597..9652cb7d56 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -55,6 +55,8 @@ function parseArguments(raw: string): Record { * NAME (pi-ai's `toolName`), which the harness doesn't carry on the result * block — it is recovered from the preceding assistant tool-call with the * same id. + * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. + * @returns the pi-ai context; `tools` is omitted entirely when the request declares none. */ export function toPiContext(options: GenerateOptions): PiContext { const toolNames = new Map() @@ -159,7 +161,11 @@ function emptyPiUsage(): PiUsage { } } -/** Map pi-ai usage (reasoning folded into output by pi-ai). */ +/** + * Map pi-ai usage (reasoning folded into output by pi-ai). + * @param usage - cumulative usage from the terminal pi-ai event. + * @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence). + */ export function mapUsage(usage: PiUsage): TokenUsage { return { inputTokens: usage.input, @@ -177,7 +183,11 @@ function classifyPiAiError(message: string): string { return 'PI_AI_ERROR' } -/** Map a terminal pi-ai event to the harness finish reason. */ +/** + * Map a terminal pi-ai event to the harness finish reason. + * @param message - the assistant message carried by the `done` or `error` event. + * @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text. + */ export function mapStopReason(message: AssistantMessage): FinishReason { switch (message.stopReason) { case 'stop': return { kind: 'stop' } @@ -195,6 +205,9 @@ export function mapStopReason(message: AssistantMessage): FinishReason { * Translate the pi-ai event stream into StreamChunks. pi-ai never throws * mid-stream — failures arrive as `error` events, which become error/aborted * `finish` chunks (the harness protocol's other error-delivery style). + * @param events - one assistant turn's pi-ai event stream. + * @returns the harness chunks, ending with `usage` then `finish`; throws + * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */ export async function* toStreamChunks(events: AsyncIterable): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index bef0d4b3f5..43468ba507 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -29,6 +29,11 @@ export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert. export const name = 'llm-pi-ai' export const inject = ['llm'] +/** + * Plugin config, validated by the same-named schemastery schema. Every field + * is optional in yml: credentials/endpoint fall back to the environment (a + * missing API key fails plugin load, not the first call). + */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ apiKey?: string diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 328ef01c54..1b8ba6e60c 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -40,6 +40,8 @@ export class BlockAssembler { /** * Feed one chunk. Returns the completed block when the chunk closes one * (an explicit `block-end`), otherwise undefined. + * @param chunk - the next raw chunk, in stream order. + * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk. */ push(chunk: StreamChunk): ContentBlock | undefined { switch (chunk.type) { @@ -123,20 +125,29 @@ export class BlockAssembler { return partial } - /** Assemble all blocks seen so far, in stream order. */ + /** + * Assemble all blocks seen so far, in stream order. + * @returns one block per seen index; an open block assembles from its + * accumulated deltas (an unknown block type never closed by `block-end` throws). + */ blocks(): ContentBlock[] { return this.order.map(index => this.assemble(this.mustGet(index), index)) } + /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined { return this._usage } + /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ get finish(): FinishReason { return this._finish ?? { kind: 'stop' } } - /** The assembled assistant message. */ + /** + * The assembled assistant message. + * @returns an assistant-role message over `blocks()` (same open-block assembly rules). + */ message(): Message { return { role: 'assistant', content: this.blocks() } } diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index 61ee2f9ddd..0250062eec 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -54,6 +54,8 @@ export const APP_IDENTITY: AppIdentity = { * The standard `User-Agent` value: `product/version (+url)`. The * parenthesized `+url` comment is the conventional self-identification form * (RFC 9110 §10.1.5 product + comment syntax). + * @param identity - the identity to render; defaults to {@link APP_IDENTITY}. + * @returns the ready-to-send header value. */ export function userAgent(identity: AppIdentity = APP_IDENTITY): string { return `${identity.product}/${identity.version} (+${identity.url})` @@ -63,6 +65,8 @@ export function userAgent(identity: AppIdentity = APP_IDENTITY): string { * Build the attribution headers an adapter must send on every provider * request. Header names are lowercase (HTTP field names are case-insensitive * on the wire). + * @param identity - the identity to send; defaults to {@link APP_IDENTITY} — omission cannot suppress attribution. + * @returns headers to merge into the provider request (currently just `user-agent`). */ export function attributionHeaders( identity: AppIdentity = APP_IDENTITY, diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index 3082cc0141..ee1cf786b1 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -17,7 +17,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand' */ export type CallId = Branded<'CallId'> -/** Brand a string as a {@link CallId}. */ +/** + * Brand a string as a {@link CallId}. + * @param id - the provider-issued (or synthesized) call id. + * @returns the same string, branded; no validation is performed. + */ export function CallId(id: string): CallId { return id as CallId } diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 6f28898798..2627455789 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -18,6 +18,7 @@ * `ErrorOptions`. `name` defaults to the subclass constructor name. */ export class HarnessError extends Error { + /** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */ readonly code: string constructor(message: string, code: string, options?: ErrorOptions) { @@ -27,7 +28,11 @@ export class HarnessError extends Error { } } -/** Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). */ +/** + * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). + * @param value - the caught value (`unknown` in catch clauses). + * @returns true only for real instances; duck-typed or cross-realm errors do not narrow. + */ export function isHarnessError(value: unknown): value is HarnessError { return value instanceof HarnessError } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 0bab45e1bb..667e9bca78 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -73,7 +73,11 @@ export class LlmError extends HarnessError { * same value to the wire. */ export abstract class LlmAdapter { - /** Stream one model call as raw chunks. The only required method. */ + /** + * Stream one model call as raw chunks. The only required method. + * @param options - the fully-assembled request; implementations must honor `options.signal`. + * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`. + */ abstract stream(options: GenerateOptions): AsyncIterable } diff --git a/packages/llm/llm/src/never.ts b/packages/llm/llm/src/never.ts index 8eed137f15..1243611415 100644 --- a/packages/llm/llm/src/never.ts +++ b/packages/llm/llm/src/never.ts @@ -26,6 +26,9 @@ * variant was added without updating the switch (compile error at the call * site — the desired outcome) or a value escaped its type (runtime throw * with diagnostics — the safety net). + * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site. + * @param context - optional label (e.g. the switch site) prefixed into the throw message. + * @returns never — it always throws, with the offending value JSON-rendered in the message. */ export function assertNever(value: never, context?: string): never { // JSON.stringify is typed string but returns undefined for undefined input; diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 7163ddc1d9..1644885a73 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -70,7 +70,9 @@ export interface ContentBlockMap { 'tool-result': ToolResultBlock } +/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */ export type ContentBlockType = keyof ContentBlockMap +/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */ export type ContentBlock = ContentBlockMap[ContentBlockType] /** A single message in a conversation history. */ @@ -88,6 +90,7 @@ export interface MessageSourceMap { plugin: { kind: 'plugin'; plugin: string } } +/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */ export type MessageSource = MessageSourceMap[keyof MessageSourceMap] /** @@ -102,6 +105,7 @@ export interface FinishReasonMap { 'error': { kind: 'error'; message: string; code?: string } } +/** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */ export type FinishReason = FinishReasonMap[keyof FinishReasonMap] /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 63cf899e45..5ec01283b4 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -27,7 +27,11 @@ export interface HeaderLine { seedLength?: number } -/** Build the header line object from a {@link SessionHeader}. */ +/** + * Build the header line object from a {@link SessionHeader}. + * @param header - the immutable session metadata to serialize. + * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null). + */ export function toHeaderLine(header: SessionHeader): HeaderLine { return { type: 'session', @@ -40,7 +44,11 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { } } -/** Parse a header line back into a {@link SessionHeader}. */ +/** + * Parse a header line back into a {@link SessionHeader}. + * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard). + * @returns the header, absent optional fields omitted. + */ export function fromHeaderLine(line: HeaderLine): SessionHeader { return { version: line.version, @@ -77,6 +85,8 @@ function isHeaderLine(value: unknown): value is HeaderLine { * `Buffer.from(…, 'utf8')` would do, breaking injectivity). `.` is in the safe * set for readability but the whole-segment tokens `.`/`..` are escaped so they * can never traverse. + * @param raw - the string to encode; must be non-empty (throws on `''`). + * @returns the escaped single path segment, decodable back to `raw`. */ export function encodeSegment(raw: string): string { if (raw.length === 0) throw new Error('cannot encode an empty path segment') @@ -97,9 +107,12 @@ export function encodeSegment(raw: string): string { /** * The directory a session's files live in: the configured root, then a per-cwd - * subdirectory so sessions group by project. The cwd subdir is a stable hash - * (short, collision-resistant, filesystem-safe) plus an encoded suffix for - * readability; sessions without a cwd go in a shared `_no-cwd` bucket. + * subdirectory so sessions group by project. The cwd subdir is a stable hash of + * the cwd (short, collision-resistant, filesystem-safe); sessions without a + * cwd go in a shared `_no-cwd` bucket. + * @param root - the backend's session root directory. + * @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket. + * @returns the per-cwd bucket directory path under `root`. */ export function sessionDir(root: string, cwd: string | undefined): string { if (cwd === undefined) return join(root, '_no-cwd') @@ -107,12 +120,22 @@ export function sessionDir(root: string, cwd: string | undefined): string { return join(root, `cwd-${hash}`) } -/** The append-only event-log file path for a session. */ +/** + * The append-only event-log file path for a session. + * @param root - the backend's session root directory. + * @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`). + * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use. + * @returns the session's `.jsonl` log file path. + */ export function logPath(root: string, cwd: string | undefined, id: SessionId): string { return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`) } -/** Serialize one event as a JSONL line (no trailing newline). */ +/** + * Serialize one event as a JSONL line (no trailing newline). + * @param event - the event to serialize verbatim. + * @returns the event's single-line JSON text; the writer adds the newline. + */ export function eventLine(event: SessionEvent): string { return JSON.stringify(event) } @@ -135,6 +158,9 @@ export function eventLine(event: SessionEvent): string { * This relies on the session-log invariant that every event lives inside a turn * (`Session.append` enforces it): only the final turn can be open, so the * preserved tail is at most one unclosed turn. + * @param buffer - the raw bytes of the log file (header line first). + * @returns the header, the preserved event prefix, and `committedBytes` — the + * byte offset the next append truncates any torn tail to. */ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { const text = buffer.toString('utf8') @@ -239,6 +265,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE * `undefined` if it is missing/not a header. Used by `list()` to read session * metadata WITHOUT parsing the whole log: a session picker scales with the * number of sessions, not the total size of every conversation. + * @param firstLine - the first line of a log file (without its trailing newline). + * @returns the parsed header, or `undefined` when the line is not a well-formed session header. */ export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { let parsed: unknown diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 6de7eafeb3..a69c979756 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -31,6 +31,7 @@ import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, } from './format.ts' +/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */ export interface Config { /** * Root directory for all session files. Required (no default): a default of diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2ed6f5853c..2dacbe04a0 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -76,6 +76,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * is the merged layout carrying every column; bumping past the collided v3 * makes the version check reject both sibling v3 databases instead of opening * one against columns it does not have. + * @param path - the SQLite database file to open (created when absent). + * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. + * @returns the open handle with pragmas applied and both tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -120,7 +123,11 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy return db } -/** Reconstruct the {@link SessionHeader} from a `sessions` row. */ +/** + * Reconstruct the {@link SessionHeader} from a `sessions` row. + * @param row - the `sessions` table row. + * @returns the header, `NULL` columns mapped to omitted optional fields. + */ export function rowToMeta(row: SessionRow): SessionHeader { return { version: row.version, @@ -132,7 +139,12 @@ export function rowToMeta(row: SessionRow): SessionHeader { } } -/** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */ +/** + * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). + * @param row - the `events` table row; `data` and the surface columns hold JSON text. + * @returns the reconstructed event; throws when a JSON column fails to parse + * ({@link scanRows} treats that as a hole, not corruption, in the tail). + */ export function rowToEvent(row: EventRow): SessionEvent { // Surface-metadata fields are conditional on the event type in the type // system; spread them so each variant gets only the fields it declares. @@ -172,6 +184,9 @@ export function rowToEvent(row: EventRow): SessionEvent { * This relies on the session-log invariant that every event lives inside a turn * (`Session.append` enforces it): only the final turn can be open, so the * preserved tail is at most one unclosed turn. + * @param rows - one session's event rows, ordered by seq ascending. + * @returns the preserved event prefix, plus `tornFrom` — the seq the physical + * delete starts at — when a torn tail exists. */ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } { // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 7c7b044ee1..0180999842 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -186,6 +186,7 @@ export class PersistenceCoordinator { /** * Register a new session's metadata (lazy: no physical write until the first * {@link append}). Rejects if the id is already tracked or already persisted. + * @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time. */ create(meta: SessionHeader): Promise { // Snapshot the metadata at call time: the op runs later (behind the @@ -216,6 +217,8 @@ export class PersistenceCoordinator { /** * Durably persist a batch of events. Honors the append-only and contiguous-seq * contracts; rejects non-JSON-serializable `event.data`. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order; deep-cloned at call time. */ async append(id: SessionId, events: readonly SessionEvent[]): Promise { // Validate serializability BEFORE cloning so a bad event surfaces the typed @@ -252,6 +255,8 @@ export class PersistenceCoordinator { * Reload a session: its {@link SessionHeader} plus the event log up to the last * durable checkpoint, with any interrupted final turn durably closed (synthetic * boundary events) during load. + * @param id - the persisted session to reload. + * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.serialize(id, () => this.loadCore(id)) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 5e1af6e563..1588ed2526 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -45,6 +45,9 @@ declare module 'cordis' { * * The comparison includes the full event payload, not just seq/type/time, so a * mutated seed cannot be grafted onto a durable log with the same envelope. + * @param seed - the live session's creation-time event snapshot. + * @param prefix - the persisted prefix the seed must reproduce. + * @returns `true` when the prefix fits within the seed and every event matches by JSON text. */ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { return prefix.length <= seed.length @@ -58,6 +61,7 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly * Reject non-JSON-serializable event data before a backend serializes a batch. * Live session appends already enforce this; persistence append paths also * accept replay/fork batches that may bypass a live session instance. + * @param events - the batch to validate; throws naming the offending event's type and seq. */ export function assertSerializable(events: readonly SessionEvent[]): void { for (const event of events) { diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 74291b7e65..d7631f4d8d 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -121,7 +121,12 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 */ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i -/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ +/** + * The ambient env minus credential-shaped vars, plus the spec's explicit env. + * @param extra - explicit vars layered on top AFTER the scrub, so a + * credential-shaped name supplied deliberately still reaches the child. + * @returns the environment to spawn the child with. + */ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { @@ -130,7 +135,12 @@ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv return { ...env, ...extra } } -/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */ +/** + * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. + * @param reason - the terminal reason from the child's `session/prompt` response. + * @returns the harness equivalent; `max_turn_requests` and any unknown future + * variant map to `error`, so an unclean stop is never reported as `completed`. + */ export function acpStopReason(reason: StopReason): SubagentStopReason { switch (reason) { case 'end_turn': @@ -155,12 +165,20 @@ export function acpStopReason(reason: StopReason): SubagentStopReason { } } -/** Collect the text of an ACP content block (non-text blocks contribute nothing). */ +/** + * Collect the text of an ACP content block (non-text blocks contribute nothing). + * @param content - the content block off a streamed `agent_message_chunk`. + * @returns the block's text, or `''` for a non-text block. + */ export function acpContentText(content: AcpContentBlock): string { return content.type === 'text' ? content.text : '' } -/** Translate the harness prompt blocks into ACP prompt blocks (text only). */ +/** + * Translate the harness prompt blocks into ACP prompt blocks (text only). + * @param prompt - the harness prompt; non-text blocks are dropped. + * @returns the ACP text blocks, in order. + */ export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] { const blocks: AcpContentBlock[] = [] for (const block of prompt) { @@ -206,6 +224,11 @@ function exitsWithin(child: ChildProcess, ms: number): Promise { * failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per * the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the * subprocess and awaits its exit (quiescent teardown). + * @param request - the start request; the driver consumes `prompt` and `signal` + * (an already-aborted signal yields an inert `aborted` run with no spawn). + * @param spec - the resolved spawn spec: command/args/cwd, env, permission + * policy, dispose graces, and the optional error sink. + * @returns the live run handle for the child subprocess. */ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun { const id = AgentId(randomUUID()) diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index b6d0c10e44..1cd00472e9 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -47,6 +47,8 @@ export const Config: z = z.object({ * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live * log keeps `seq === index`), so it is a valid session seed; the in-flight, * unbalanced turn is dropped so the invariants replay accepts it. + * @param parent - the agent whose session log to slice. + * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ export function completedTurnPrefix(parent: Agent): SessionEvent[] { const events = parent.session.events diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1107926aa2..4b657ee810 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -34,7 +34,11 @@ declare module '@deepseek-ai/dsh-agent' { } } -/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */ +/** + * Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). + * @param agent - the agent whose options may carry `subagentDepth`. + * @returns 0 for a top-level agent, its parent's depth + 1 for a subagent. + */ export function depthOf(agent: Agent): number { return agent.options.subagentDepth ?? 0 } @@ -88,6 +92,13 @@ export interface InProcessRunOptions { * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove * session); `cancel()` cancels the child's in-flight turn. + * + * Throws {@link SubagentDepthError} before creating anything when the child's + * depth (parent depth + 1) would exceed `request.maxDepth`. + * @param ctx - the context whose `agents` factory creates and owns the child. + * @param request - the start request (prompt, parent, signal, per-child options). + * @param options - the backend's inputs: provider name plus the optional seed. + * @returns the live run handle for the child agent. */ export function startInProcessRun( ctx: Context, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index ef76a96e5d..e88c77428a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -93,6 +93,7 @@ export interface SubagentStopReasonMap { refusal: 'refusal' } +/** The union over {@link SubagentStopReasonMap} — widens automatically as backends merge in variants. */ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap] /** diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 78f46e705e..3a09947231 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -124,6 +124,8 @@ export interface SessionScript { * Parse a session `.jsonl` buffer into its event list. Line 0 is the session * header (a `{type:'session',…}` record), every subsequent non-empty line is a * {@link SessionEvent}. The header is skipped; malformed lines fail loud. + * @param text - the raw `.jsonl` file contents. + * @returns every event after the header, in log order. */ export function parseSessionLog(text: string): SessionEvent[] { const lines = text.split('\n').filter(line => line.trim().length > 0) @@ -147,6 +149,8 @@ export function parseSessionLog(text: string): SessionEvent[] { * own model calls; absent ⇒ 0). A header missing a field falls back to a stable * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is * header-only and still orders fine as the single (primary) script. + * @param text - the raw `.jsonl` file contents (only the header line is read). + * @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent. */ export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } { const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}' @@ -176,6 +180,8 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe * sidecar with an explicit `throw` (or `hang`) entry instead. {@link * deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing * override fails loud rather than silently replaying a thrown call as success. + * @param events - the recorded session's events; only `assistant/chunk` is consulted. + * @returns one `chunks` entry per recorded model call, in call order. */ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { const script: ReplayEntry[] = [] @@ -214,6 +220,8 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { * Fail-loud if the JSONL fixture is missing (the scenario was never recorded) — * never silently returns an empty script, so a coverage hole can't masquerade * as a passing replay. + * @param config - the fixture paths; only `file` and `overrideFile` are consulted. + * @returns the primary session's replay entries. */ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { if (config.overrideFile !== undefined && existsSync(config.overrideFile)) { @@ -241,6 +249,8 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { * the parent issues the FIRST model call (it must stream before it can delegate * in the synchronous nested cut), so binding it to the first live session is * correct regardless of a timestamp tie. + * @param config - the fixture paths: the primary log plus any recorded child logs. + * @returns the primary script first, then the child scripts in bind order. */ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { const primaryEntries = loadReplayScript(config) @@ -355,6 +365,9 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * Each per-session cursor advances synchronously at listener-invocation time * (not lazily inside the generator) so call ORDER within a session, not * iteration order, fixes the mapping. + * @param ctx - the context whose `llm/stream` waterfall the listener short-circuits. + * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job). + * @returns the `ctx.on` disposer that removes the listener. */ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { const scripts = loadSessionScripts(config) @@ -408,6 +421,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void export const name = 'llm-replay' export const inject = ['llm'] +/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */ export interface Config { /** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */ file?: string diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 444e71545c..3830dba676 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -39,6 +39,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr * hook before any step ran — ACP has no "rejected" reason, and a * blocked prompt is, from the client's view, the prompt not being * carried out; `cancelled` is the closest legal wire reason) + * @param reason - the harness turn-end reason to translate. + * @returns the legal ACP wire value per the mapping above. */ export function turnEndToStopReason(reason: TurnEndReason): StopReason { switch (reason.kind) { @@ -71,6 +73,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { * `reasoning` is surfaced via `agent_thought_chunk` * streaming rather than as a message block, and `tool-call`/`tool-result` * are handled by the tool-call update path. + * @param block - the harness content block to translate. + * @returns the ACP block, or `undefined` for a kind with no message-content mapping. */ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined { switch (block.type) { @@ -89,6 +93,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | * concatenated verbatim; resource links become explicit textual references so * baseline ACP clients can point at files without the bridge silently dropping * that context. + * @param prompt - the ACP prompt blocks to flatten. + * @returns the concatenated text, with resource links rendered as bracketed references. */ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { return prompt @@ -109,6 +115,8 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { * Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP * requires `text` and `resource_link`; richer inline payloads (`resource`, * image, audio, …) are rejected rather than silently dropped. + * @param prompt - the ACP prompt blocks to inspect. + * @returns `true` when any block is neither `text` nor `resource_link`. */ export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a819e607d0..b4fa277311 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -701,6 +701,8 @@ export function apply(ctx: Context, config: AcpConfig): void { * Build per-agent options from the plugin config, omitting absent fields * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. + * @param config - the plugin config carrying the optional model name. + * @returns the per-agent options, with `model` present only when configured. */ export function agentOptions(config: AcpConfig): { model?: string } { return { @@ -764,6 +766,16 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * * Other event types (turn/step boundaries, context/message, …) produce * no client update. + * @param sessionId - the ACP session id stamped on every emitted notification. + * @param event - the harness session event to translate. + * @param notify - sink for each produced `session/update` notification; called + * zero or more times per event (best-effort UI feed, never load-bearing). + * @param presenter - resolves tool-owned render intent for tool events; + * defaults to the generic-fallback {@link nullToolPresenter}. + * @param terminal - the connection's terminal-rendering context; defaults to + * disabled (the plain-text console-block fallback). + * @param options - `includeUserMessages` (default `true`): live streaming + * passes `false` so a prompt the client just sent is not echoed back. */ export function streamSessionEventUpdate( sessionId: SessionId, @@ -825,6 +837,8 @@ export function streamSessionEventUpdate( * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole * plan on each `plan` update, matching the harness's whole-list-replace * semantics, so no per-entry diffing is needed. + * @param todos - the harness todo list (the whole list, not a diff). + * @returns the ACP plan body, one entry per todo. */ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } @@ -885,7 +899,16 @@ export class ToolPresenter { private readonly onError: (message: string) => void = () => {}, ) {} - /** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */ + /** + * Pending-state render intent for a `tool/call`; remembers `(name, args, card)` + * for the matching result. + * @param callId - the call id the matching `tool/result` will look up. + * @param name - the tool name, resolved against the registry for `presentCall`. + * @param argsJson - the raw arguments JSON from the event; parsed for the view + * (a non-JSON string is surfaced raw). + * @returns the tool-owned view, or the generic fallback (title = tool name, + * kind `other`, parsed args as raw input) when the tool defines none or threw. + */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) let present: ToolCallView | undefined @@ -905,7 +928,18 @@ export class ToolPresenter { return view } - /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */ + /** + * Completed-state render intent for a `tool/result`; consumes the remembered + * `(name, args, card)`. + * @param callId - the id of the matching `tool/call`; an unknown or late id + * falls back to the raw content. + * @param content - the result's content blocks (the fallback and fill-in body). + * @param isError - whether the result is an error, forwarded to `presentResult`. + * @param meta - the result's machine-readable meta, forwarded when present. + * @returns the tool-owned view — an orphaned `terminal` result (no terminal + * call side) and a content-less `generic` are normalized — or the raw-content + * generic card when the tool defines no `presentResult` or threw. + */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 5515501188..8f3392e913 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -36,6 +36,10 @@ import Loader from '@cordisjs/plugin-loader' * the SAME directory (the keyless replay tree). Other modes — including no * snapshot mode at all — use the path as-is. Returns an absolute path resolved * from `cwd`. + * @param configPath - the requested config path (absolute, or relative to `cwd`). + * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename. + * @param cwd - the base a relative `configPath` resolves against. + * @returns the absolute path of the config to boot. */ export function resolveConfigPath( configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), @@ -54,6 +58,9 @@ export function resolveConfigPath( * them via the `!!js` tag. A present-but-unreadable `.env` is a real * misconfiguration: surface it via `warn` (one line, default stderr) rather * than silently running with the wrong environment. + * @param binName - the diagnostic prefix on the warn line. + * @param dir - the directory whose `.env` to load. + * @param warn - sink for the one-line misconfiguration diagnostic. */ export function loadEnv( binName: string, dir: string = process.cwd(), @@ -90,6 +97,9 @@ export interface FailLoudProcess { * STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and * guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller * (tests use it; the bins run until exit and never do). + * @param binName - the diagnostic prefix on the fatal-failure line. + * @param proc - the process slice to register on; tests inject a fake. + * @returns the uninstaller that removes the rejection handler. */ export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { const handler = (err: unknown): void => { @@ -108,6 +118,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process * entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately * skips `init()` for it — a valid "plugin turned off" config, not a failed * import — so it is excluded. + * @param ctx - the settled context whose loader entries to audit. + * @param binName - the diagnostic prefix on the thrown error. */ export function assertEntriesLoaded(ctx: Context, binName: string): void { const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) @@ -139,6 +151,10 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * active under `node --expose-internals`; a consumer running a built bin must * pass that flag (or install the plugins where node hoists them). Relative * specifiers resolve against the config directory with no flag. + * @param binName - the diagnostic prefix for load-failure errors. + * @param absoluteConfigPath - the config to include; must already be absolute + * (see {@link resolveConfigPath}). + * @returns the root context once every entry has started. */ export async function boot(binName: string, absoluteConfigPath: string): Promise { const ctx = new Context() diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 2996e11e89..78b0a5ae5b 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -63,6 +63,10 @@ function isTTYPair(input: Readable, output: Writable): boolean { * directly with fakes. Returns nothing — all registration is via `ctx.on`/ * `ctx.effect`, so fiber disposal tears every listener and the readline * interface down. + * @param ctx - the context supplying the `agents` service and the event feeds. + * @param config - the plugin config; defaults are re-applied here for direct + * callers that bypass Loader validation. + * @param runtime - the process-I/O seam (line source, render sink, exit hook). */ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { // Default here too (not just via schemastery's `.default()`): this helper is diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 5f7334d952..9c9243e90f 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -14,7 +14,13 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' import { htmlToMarkdown } from './html.ts' -/** Validate value constraints the schema DSL can't express. */ +/** + * Validate value constraints the schema DSL can't express: a non-blank `url`, + * and a positive `timeout_ms` when present. Throws a plain `Error` otherwise. + * + * @param args - the schema-validated `web_fetch` arguments. + * @returns the arguments renamed to the seam's camelCase request fields. + */ export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } { if (args.url.trim().length === 0) throw new Error('url must be a non-empty string') if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) { @@ -23,7 +29,13 @@ export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { ur return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} } } -/** Render a fetched body to model-facing markdown text. */ +/** + * Render a fetched body to model-facing markdown text. + * + * @param body - the decoded body; `html` is converted via + * {@link htmlToMarkdown}, `text` passes through verbatim. + * @returns the text for the tool's output block. + */ export function renderBody(body: WebFetchBody): string { switch (body.kind) { case 'html': @@ -36,19 +48,35 @@ export function renderBody(body: WebFetchBody): string { } } -/** Format a fetch result as one model-facing text block. */ +/** + * Format a fetch result as one model-facing text block. + * + * @param result - the seam's fetch outcome. + * @returns a `Fetched (HTTP )` header, the rendered body, and a + * fetch-something-narrower notice when the provider truncated the content. + */ export function formatFetchOutput(result: WebFetchResult): string { const header = `Fetched ${result.url} (HTTP ${result.statusCode})` const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : '' return `${header}\n\n${renderBody(result.body)}${footer}` } -/** Pending-call presentation: a fetch card titled by the URL. */ +/** + * Pending-call presentation: a fetch card titled by the URL. + * + * @param args - the raw tool arguments; only `url` feeds the view. + * @returns the generic card view (`kind: 'fetch'`) shown while the call runs. + */ export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView { return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url } } -/** Register the `web_fetch` tool and its system-prompt guidance. */ +/** + * Register the `web_fetch` tool and its system-prompt guidance. + * + * @param ctx - context whose `tools` and `systemPrompt` registries receive the + * registrations; both are effect-scoped and unregister on plugin dispose. + */ export function applyWebFetchTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:web_fetch', diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts index 622be86fd5..d848ad6225 100644 --- a/packages/web/tool-web/src/html.ts +++ b/packages/web/tool-web/src/html.ts @@ -44,6 +44,10 @@ function safeFromCodePoint(code: number, fallback: string): string { * Convert an HTML document to a readable markdown-ish text approximation. * Best-effort and lossy by design — fidelity is the job of a future heavier * converter, not this fallback. + * + * @param html - the raw HTML source. + * @returns plain text with markdown headings, list bullets, and links; + * whitespace collapsed to at most one blank line and trimmed. */ export function htmlToMarkdown(html: string): string { let text = html diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index c0191c023b..78b6a4bdf3 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -33,6 +33,7 @@ export const name = 'tool-web' /** Services required by the web tool suite. */ export const inject = ['tools', 'web', 'systemPrompt'] +/** Plugin config: which web tools to register, and the `web_search` source cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 28e4e9a2e9..3776940cde 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -20,7 +20,13 @@ import type {} from '@deepseek-ai/dsh-system-prompt' */ export const WEB_SEARCH_MAX_RESULTS = 8 -/** Validate value constraints the schema DSL can't express. */ +/** + * Validate value constraints the schema DSL can't express: a non-blank + * `query`. Throws a plain `Error` otherwise. + * + * @param args - the schema-validated `web_search` arguments. + * @returns the accepted arguments, passed through unchanged. + */ export function parseSearchArgs(args: { query: string }): { query: string } { if (args.query.trim().length === 0) throw new Error('query must be a non-empty string') return { query: args.query } @@ -38,7 +44,14 @@ function sourceLabel(url: string, title: string | undefined): string { } } -/** Format a search result as one model-facing text block. */ +/** + * Format a search result as one model-facing text block. + * + * @param result - the seam's search outcome. + * @returns the provider answer (when any), a markdown source list with snippet + * and date metadata (or `No results found.`), a refine-the-query note when + * truncated, and a standing cite-your-sources instruction. + */ export function formatSearchOutput(result: WebSearchResult): string { const parts: string[] = [] if (result.content !== undefined && result.content.length > 0) parts.push(result.content) @@ -62,12 +75,24 @@ export function formatSearchOutput(result: WebSearchResult): string { return parts.join('\n\n') } -/** Pending-call presentation: a search card titled by the query. */ +/** + * Pending-call presentation: a search card titled by the query. + * + * @param args - the raw tool arguments; only `query` feeds the view. + * @returns the generic card view (`kind: 'search'`) shown while the call runs. + */ export function presentSearchCall(args: { query: string }): GenericCallView { return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } } -/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */ +/** + * Register the `web_search` tool and its system-prompt guidance. + * + * @param ctx - context whose `tools` and `systemPrompt` registries receive the + * registrations; both are effect-scoped and unregister on plugin dispose. + * @param maxResults - the deployment's source cap, sent as every seam + * request's `maxResults`. + */ export function applyWebSearchTool(ctx: Context, maxResults: number): void { ctx.systemPrompt.section({ name: 'tool:web_search', diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 7eb614f39a..b6f97bf0d9 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -30,6 +30,7 @@ export const name = 'web-fetch-local' /** The web seam this provider registers into. */ export const inject = ['web'] +/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */ export interface Config { /** Maximum accepted request URL length. */ maxUrlLength?: number diff --git a/packages/web/web-fetch-local/src/policy.ts b/packages/web/web-fetch-local/src/policy.ts index 7a76bd1af1..f37697765a 100644 --- a/packages/web/web-fetch-local/src/policy.ts +++ b/packages/web/web-fetch-local/src/policy.ts @@ -16,6 +16,10 @@ export type FetchableKind = 'html' | 'text' * enforces before any network access: http(s) only, no embedded credentials, * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise. * (SSRF / private-network blocking is deferred — see the package RFC.) + * + * @param input - the raw URL string from the fetch request. + * @param maxUrlLength - inclusive upper bound on `input`'s length. + * @returns the parsed `URL`. */ export function validateFetchUrl(input: string, maxUrlLength: number): URL { if (input.length > maxUrlLength) { @@ -40,6 +44,10 @@ export function validateFetchUrl(input: string, maxUrlLength: number): URL { * Two URLs are same-origin when scheme, hostname, and port match. A redirect * that crosses origins is refused so each new origin requires a fresh tool call * (and thus a fresh provider/permission decision). + * + * @param a - one of the two URLs to compare. + * @param b - the other URL to compare. + * @returns true when `a` and `b` share scheme, hostname, and port. */ export function isSameOrigin(a: URL, b: URL): boolean { return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port @@ -49,6 +57,10 @@ export function isSameOrigin(a: URL, b: URL): boolean { * Classify a response `Content-Type` into a decodable body kind, or `undefined` * for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml` * are `html`; other `text/*` plus a few structured text types are `text`. + * + * @param contentType - the raw `Content-Type` header, or `null` when the + * response carries none (unsupported). + * @returns the decodable kind, or `undefined` for an unsupported type. */ export function classifyContentType(contentType: string | null): FetchableKind | undefined { const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase() @@ -63,6 +75,10 @@ export function classifyContentType(contentType: string | null): FetchableKind | * or `undefined` when absent. The provider feeds this label to `TextDecoder` * so a non-UTF-8 response is decoded with its declared encoding rather than * silently mangled into replacement characters. + * + * @param contentType - the raw `Content-Type` header, or `null` when the + * response carries none. + * @returns the lower-cased charset label, or `undefined` when none is declared. */ export function parseCharset(contentType: string | null): string | undefined { const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '') @@ -74,6 +90,10 @@ export function parseCharset(contentType: string | null): string | undefined { * none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when * the label is present but not a charset `TextDecoder` recognizes — better to * fail loudly than return mojibake. + * + * @param charset - the declared charset label (from {@link parseCharset}), or + * `undefined` to default to UTF-8. + * @returns a decoder for the declared (or defaulted) encoding. */ export function decoderForCharset(charset: string | undefined): TextDecoder { if (charset === undefined) return new TextDecoder('utf-8') diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index c993fa8808..2c3f0ede3b 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -44,6 +44,7 @@ export const name = 'web-search-deepseek' /** The web seam this provider registers into. */ export const inject = ['web'] +/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ apiKey?: string diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 40566b4f75..0406f86510 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -62,6 +62,7 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5 /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' +/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface DeepSeekSearchProviderOptions { /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ apiKey: string @@ -82,6 +83,9 @@ export interface DeepSeekSearchProviderOptions { * is the snippet surface: Anthropic `web_search_result` items carry * `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives * in a separate `text` block's citation, keyed by `url` (first occurrence wins). + * + * @param blocks - the response's content blocks; non-`text` blocks are skipped. + * @returns the `url → cited_text` map (empty when no citations are present). */ export function citationSnippets(blocks: readonly ContentBlock[]): Map { const map = new Map() @@ -106,6 +110,10 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map highlight.trim().length > 0) @@ -66,7 +71,14 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined { } } -/** Map an Exa response envelope to a normalized search result. */ +/** + * Map an Exa response envelope to a normalized search result. + * + * @param query - the original request query, echoed on the result. + * @param response - the parsed `POST /search` response body. + * @returns the normalized result; snippet-less entries are dropped + * ({@link mapExaResult}). + */ export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { const sources = (response.results ?? []) .map(mapExaResult) diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index 3d375eaabb..b71d5052d6 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -29,6 +29,7 @@ export const name = 'web-search-perplexity' /** The web seam this provider registers into. */ export const inject = ['web'] +/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ apiKey?: string diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index ed72ea82c3..8338c6f616 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -41,6 +41,7 @@ export type PerplexityRecency = 'day' | 'week' | 'month' | 'year' /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' +/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface PerplexitySearchProviderOptions { /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ apiKey: string @@ -54,7 +55,12 @@ export interface PerplexitySearchProviderOptions { searchRecency?: PerplexityRecency } -/** Map one structured Perplexity search result to a normalized source. */ +/** + * Map one structured Perplexity search result to a normalized source. + * + * @param result - one entry of the response's `search_results[]`. + * @returns the normalized source; blank fields are omitted rather than set empty. + */ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource { return { url: result.url, @@ -68,6 +74,10 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo * Map a Perplexity response envelope to a normalized search result. Prefers * structured `search_results[]`; falls back to URL-only `citations[]` (those * sources carry just a `url`) only when `search_results` is absent. + * + * @param query - the original request query, echoed on the result. + * @param response - the parsed chat-completions response body. + * @returns the normalized result; `content` is omitted when the answer is empty. */ export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult { const content = response.choices?.[0]?.message?.content diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4a7a417e02..cbdf7a688d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -40,7 +40,9 @@ * a stale `@param` naming no real parameter errors. Violations aggregate into * ONE error listing every offender. The tags are enforcement-only: parseJsDoc * stops prose at the first block tag, so they never change the rendered - * catalog. The INHERITED + * catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with + * the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so + * "documented" means the same thing on both surfaces. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author * also sees; it is rendered tersely (name + one-line + source pointer) from a * curated table in this script, NOT elevated to the harness tier's prominence. @@ -53,6 +55,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import ts from 'typescript' +import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT_EVENTS = 'docs/cordis-catalog/events.md' @@ -62,9 +65,6 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md' * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ const FENCE = 'ts cordis-catalog' -/** A dispatch mode, rendered as the badge after an event name. */ -type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' - /** * Cross-link map: a type name that appears in a signature → the * core-data-structures page that documents it (path relative to the catalogs' @@ -146,132 +146,6 @@ interface InheritedEntry { source: string } -/** Repo-relative source pointer `file:line` for a node's first character. */ -function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string { - const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) - return `${rel}:${line + 1}` -} - -/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */ -function rawJsDoc(text: string, node: ts.Node): string { - const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [] - const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1) - return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' -} - -/** - * Parse a raw JSDoc block into description prose + the `@mode` tag (when - * present). Output obeys the repo's markdown conventions so the generated file - * passes verify-md-wrap: each prose paragraph collapses to ONE physical line, - * and a `-` bullet list is preserved with each item on its own single line - * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description - * prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and - * their continuation lines are never prose, so `@param`/`@returns` blocks are - * invisible to the rendered catalog. - */ -function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { - const inner = raw - .replace(/^\/\*\*/, '') - .replace(/\*\/$/, '') - .split('\n') - .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) - let mode: Mode | null = null - let inTags = false - const blocks: string[] = [] - let para: string[] = [] - let list: string[] = [] - let item: string[] = [] - const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim() - const flushItem = (): void => { - if (item.length) list.push(join(item)) - item = [] - } - const flushList = (): void => { - flushItem() - if (list.length) blocks.push(list.join('\n')) // one block, items on own lines - list = [] - } - const flushPara = (): void => { - flushList() - if (para.length) blocks.push(join(para)) - para = [] - } - for (const line of inner) { - const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) - if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue } - if (line.startsWith('@')) { flushPara(); inTags = true; continue } - if (inTags) continue // block-tag territory: continuations are never prose - if (line.trim() === '') { flushPara(); continue } - if (/^-\s+/.test(line)) { - // A list item starts: a pending paragraph (e.g. an intro line directly - // above the list, no blank between) flushes FIRST so it renders above. - flushItem() - if (para.length) { blocks.push(join(para)); para = [] } - item.push(line) - continue - } - if (item.length) { item.push(line); continue } // continuation of current item - para.push(line) - } - flushPara() - const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim() - return { doc, mode } -} - -/** - * Parse the block tags of a raw JSDoc comment for the completeness checks: - * every `@param name — description` entry plus the `@returns` description. - * Standard JSDoc block-tag semantics — a tag's description runs across - * continuation lines until the next tag or a blank line, and the `-`/`—` - * separator after a param name is optional. `[name]` optional-brackets unwrap - * to `name`. Rendering never sees these: parseJsDoc stops prose at the first - * block tag. - */ -function parseTags(raw: string): { params: Map; returns: string | null } { - const inner = raw - .replace(/^\/\*\*/, '') - .replace(/\*\/$/, '') - .split('\n') - .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) - const params = new Map() - let returns: string | null = null - let sink: ((text: string) => void) | null = null - for (const line of inner) { - const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) - if (param) { - const name = (param[1] ?? '').replace(/^\[|\]$/g, '') - let acc = param[2] ?? '' - params.set(name, acc) - sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) } - continue - } - const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) - if (ret) { - let acc = ret[1] ?? '' - returns = acc - sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc } - continue - } - if (line.startsWith('@') || line.trim() === '') { sink = null; continue } - sink?.(line.trim()) - } - return { params, returns } -} - -/** - * Throw one aggregate error for every completeness violation a walk collected. - * Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a - * remediation pass sees the whole list at once instead of replaying the gate - * once per offender. - */ -function reportViolations(violations: string[]): void { - if (violations.length === 0) return - throw new Error( - `gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n` - + violations.map(v => ` ${v}`).join('\n'), - ) -} - /** Find the `declare module 'cordis'` body in a source file, or null. */ function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { for (const stmt of sf.statements) { @@ -334,27 +208,13 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { // (mode machinery, documented once by @mode semantics). Documenting an // exempt parameter anyway is allowed — only absence is checked. const { params } = parseTags(raw) - for (const p of member.parameters) { - if (!ts.isIdentifier(p.name)) { - violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`) - continue - } - const pname = p.name.text - if (pname === 'this' || (hasNext && p === last)) continue - const desc = params.get(pname) - if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) - else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) - } - for (const tag of params.keys()) { - if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { - violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) - } - } + checkParams(where, 'event', member.parameters, params, sf, + p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) } } } - reportViolations(violations) + reportViolations('gen-cordis-catalog', violations) return entries } @@ -415,35 +275,12 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { if (!raw) { violations.push(`${where} has no JSDoc.`); continue } if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) const { params, returns } = parseTags(raw) - // Every parameter needs a non-empty @param; a `this` receiver - // annotation is not payload and is exempt. - for (const p of member.parameters) { - if (!ts.isIdentifier(p.name)) { - violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`) - continue - } - const pname = p.name.text - if (pname === 'this') continue - const desc = params.get(pname) - if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) - else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) - } - for (const tag of params.keys()) { - if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { - violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) - } - } - // A non-void result needs a non-empty @returns. The return type must be - // ANNOTATED: a pure-AST walk cannot classify an inferred return. On a - // `void`/`Promise` method @returns stays optional (resolution - // timing can be worth documenting), never required. - const rt = member.type?.getText(sf).replace(/\s+/g, ' ') - if (rt === undefined) { - violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`) - } else if (!/^(void|Promise)$/.test(rt)) { - if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`) - else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`) - } + // Every parameter needs a non-empty @param (`this` receiver exempt), + // and a non-void ANNOTATED result needs a non-empty @returns — the + // shared checkers carry the exact contract. + checkParams(where, 'service', member.parameters, params, sf, + p => ts.isIdentifier(p.name) && p.name.text === 'this', violations) + checkReturns(where, member.type, returns, sf, violations) } entries.push({ key, @@ -455,7 +292,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { }) } } - reportViolations(violations) + reportViolations('gen-cordis-catalog', violations) return entries.sort((a, b) => a.key.localeCompare(b.key)) } diff --git a/scripts/jsdoc.ts b/scripts/jsdoc.ts new file mode 100644 index 0000000000..d2f6d30503 --- /dev/null +++ b/scripts/jsdoc.ts @@ -0,0 +1,216 @@ +/** + * Shared JSDoc parsing and completeness-check helpers for the documentation + * gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the + * events + `ctx.` service surface) and the export-surface gate + * (`scripts/verify-export-jsdoc.ts` — every module-level export). One home for + * the mechanics so "documented" means the same thing on every gated surface: + * description prose ends at the first block tag; every checkable parameter + * needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty + * `@returns`; a stale `@param` naming no real parameter errors. + */ + +import ts from 'typescript' + +/** Repo-relative source pointer `file:line` for a node's first character. */ +export function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + return `${rel}:${line + 1}` +} + +/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */ +export function rawJsDoc(text: string, node: ts.Node): string { + const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [] + const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1) + return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' +} + +/** A dispatch mode, rendered as the badge after an event name in the catalog. */ +export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' + +/** + * Parse a raw JSDoc block into description prose + the `@mode` tag (when + * present). Output obeys the repo's markdown conventions so the generated + * catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical + * line, and a `-` bullet list is preserved with each item on its own single + * line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. + * Description prose ends at the FIRST block tag (standard JSDoc semantics): + * tag lines and their continuation lines are never prose, so `@param` / + * `@returns` blocks are invisible to the rendered catalog. + * @param raw - the raw comment text including the JSDoc delimiters. + * @returns the collapsed description prose plus the parsed `@mode` (or null). + */ +export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + let mode: Mode | null = null + let inTags = false + const blocks: string[] = [] + let para: string[] = [] + let list: string[] = [] + let item: string[] = [] + const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim() + const flushItem = (): void => { + if (item.length) list.push(join(item)) + item = [] + } + const flushList = (): void => { + flushItem() + if (list.length) blocks.push(list.join('\n')) // one block, items on own lines + list = [] + } + const flushPara = (): void => { + flushList() + if (para.length) blocks.push(join(para)) + para = [] + } + for (const line of inner) { + const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) + if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue } + if (line.startsWith('@')) { flushPara(); inTags = true; continue } + if (inTags) continue // block-tag territory: continuations are never prose + if (line.trim() === '') { flushPara(); continue } + if (/^-\s+/.test(line)) { + // A list item starts: a pending paragraph (e.g. an intro line directly + // above the list, no blank between) flushes FIRST so it renders above. + flushItem() + if (para.length) { blocks.push(join(para)); para = [] } + item.push(line) + continue + } + if (item.length) { item.push(line); continue } // continuation of current item + para.push(line) + } + flushPara() + const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim() + return { doc, mode } +} + +/** + * Parse the block tags of a raw JSDoc comment for the completeness checks: + * every `@param name — description` entry plus the `@returns` description. + * Standard JSDoc block-tag semantics — a tag's description runs across + * continuation lines until the next tag or a blank line, and the `-`/`—` + * separator after a param name is optional. `[name]` optional-brackets unwrap + * to `name`. Rendering never sees these: parseJsDoc stops prose at the first + * block tag. + * @param raw - the raw comment text including the JSDoc delimiters. + * @returns the `@param` name→description map plus the `@returns` description + * (null when the tag is absent, '' when present but empty). + */ +export function parseTags(raw: string): { params: Map; returns: string | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const params = new Map() + let returns: string | null = null + let sink: ((text: string) => void) | null = null + for (const line of inner) { + const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) + if (param) { + const name = (param[1] ?? '').replace(/^\[|\]$/g, '') + let acc = param[2] ?? '' + params.set(name, acc) + sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) } + continue + } + const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) + if (ret) { + let acc = ret[1] ?? '' + returns = acc + sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc } + continue + } + if (line.startsWith('@') || line.trim() === '') { sink = null; continue } + sink?.(line.trim()) + } + return { params, returns } +} + +/** + * Check the `@param` half of the completeness contract for one function-like + * declaration: every checkable parameter carries a non-empty `@param`, and no + * `@param` is stale. A binding-pattern parameter is a violation (it has no name + * for `@param` to match); an exempt parameter may be documented but its absence + * is never checked. Violations append to `violations` in place. + * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`. + * @param surface - the surface noun for the binding-pattern message ("event", "service", "export"). + * @param parameters - the declaration's parameter list. + * @param tags - the parsed `@param` name→description map from parseTags. + * @param sf - the source file (for rendering a binding pattern's text). + * @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`). + * @param violations - the aggregate list violations append to. + */ +export function checkParams( + where: string, + surface: string, + parameters: readonly ts.ParameterDeclaration[], + tags: Map, + sf: ts.SourceFile, + isExempt: (p: ts.ParameterDeclaration) => boolean, + violations: string[], +): void { + for (const p of parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`) + continue + } + if (isExempt(p)) continue + const desc = tags.get(p.name.text) + if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`) + } + for (const tag of tags.keys()) { + if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } +} + +/** + * Check the `@returns` half of the completeness contract: a non-`void` / + * `Promise` return needs a non-empty `@returns`, and the return type must + * be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void + * declaration `@returns` stays optional (resolution timing can be worth + * documenting), never required. Violations append to `violations` in place. + * @param where - the offender label violations open with. + * @param typeNode - the declared return type annotation, or undefined when inferred. + * @param returns - the parsed `@returns` description from parseTags (null when absent). + * @param sf - the source file (for rendering the annotation's text). + * @param violations - the aggregate list violations append to. + */ +export function checkReturns( + where: string, + typeNode: ts.TypeNode | undefined, + returns: string | null, + sf: ts.SourceFile, + violations: string[], +): void { + if (typeNode === undefined) { + violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`) + return + } + const rt = typeNode.getText(sf).replace(/\s+/g, ' ') + if (/^(void|Promise)$/.test(rt)) return + if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`) + else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`) +} + +/** + * Throw one aggregate error for every completeness violation a walk collected. + * Aggregation (vs failing fast) is deliberate: a remediation pass sees the + * whole list at once instead of replaying the gate once per offender. + * @param gate - the reporting gate's name, prefixed to the error message. + * @param violations - the collected violation lines; no-op when empty. + */ +export function reportViolations(gate: string, violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n` + + violations.map(v => ` ${v}`).join('\n'), + ) +} diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts new file mode 100644 index 0000000000..2779a53e68 --- /dev/null +++ b/scripts/verify-export-jsdoc.ts @@ -0,0 +1,408 @@ +/** + * Verify JSDoc completeness for EVERY module-level exported name of every + * non-vendored package (each `packages///src/` tree). This is the + * mechanical form of the AGENTS.md rule "every export has a JSDoc explaining + * semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`, + * which owns `interface Events` members and `ctx.` service classes) to + * the whole export surface; the parsing + check helpers are shared via + * `scripts/jsdoc.ts` so "documented" means the same thing on both. + * + * `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender + * + * The contract, per exported declaration kind: + * + * - Every exported name needs JSDoc with non-empty description prose (prose + * ends at the first block tag, standard JSDoc semantics). + * - A function-like export (function declaration, or a const with a function + * initializer) additionally needs a non-empty `@param` per parameter + * (`this` receiver annotations exempt; a stale `@param` errors) and a + * non-empty `@returns` unless the return type is `void`/`Promise`. + * The walk classifies returns syntactically, so the return type must be + * ANNOTATED — except a const whose DECLARATOR is type-annotated (e.g. + * `export const f: Handler = …`), where the named type owns the return + * contract and `@returns` stays optional. + * - An exported class needs class-level JSDoc; its public methods (static + * included — they are reachable on the exported name) follow the function + * contract, and public properties and accessors need description prose (on + * a get/set pair the getter's doc covers both). A member whose name exists + * on an `extends`/`implements` heritage type is EXEMPT — the seam + * declaration is the doc's one home, the IDE inherits it, and re-documenting + * every implementation invites drift. This is the one question the walk + * asks the TYPE CHECKER (heritage members live across package boundaries); + * everything else is pure AST. Constructors are exempt like the cordis + * gate's: plugin classes are framework-constructed, and the class doc owns + * the story. + * - Exported interfaces, type aliases, enums: description prose on the + * declaration (member-level docs stay review's job; the highest-value + * member surface — seam service classes — is already under the cordis + * gate). + * - An exported namespace recurses (its exported members are package + * surface); the namespace itself needs prose only when it does not merge + * with an already-documented same-name declaration (the Config-namespace + * idiom documents the class/function once, not twice). + * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject` + * / `reusable` / `Config` consts and the `apply` entry, plus the same + * slots as statics on a plugin class. Their shape is fixed by the + * framework, so a doc would restate the protocol — the module doc comment + * and the `interface Config` carry the plugin's real semantics. (These + * names are reserved by cordis convention; documenting one anyway is + * allowed, only absence goes unchecked.) + * - Overload groups: each overload signature carries its own docs; the + * implementation signature is exempt (callers never see it). + * - Skipped: `declare module` / `declare global` augmentation bodies (the + * cordis gate's turf; an augmentation is not an export of the package) and + * re-export statements with a module specifier (`export … from`) — the + * defining module is walked on its own, and external definitions are not + * ours to document. + */ + +import { existsSync, globSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts' + +const root = resolve(import.meta.dirname, '..') + +/** Plugin-protocol slot names exempt as statics on an exported class. */ +const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable']) + +/** Plugin-protocol slot names exempt as top-level exports (const or function). */ +const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply']) + +/** Per-file walk state threaded through the scope recursion. */ +interface Walk { + /** Repo-relative path of the file being walked. */ + rel: string + /** The parsed source file. */ + sf: ts.SourceFile + /** Raw file text (rawJsDoc reads comment ranges out of it). */ + text: string + /** The program's checker, consulted only for heritage-member lookups. */ + checker: ts.TypeChecker + /** The aggregate violation list, appended in place. */ + violations: string[] +} + +/** True when a statement carries the `export` modifier. */ +function isExported(stmt: ts.Statement): boolean { + return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false) +} + +/** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */ +function isNonPublic(member: ts.ClassElement): boolean { + const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false) + || ('name' in member && ts.isPrivateIdentifier(member.name)) +} + +/** True when a class member carries the `static` modifier. */ +function isStatic(member: ts.ClassElement): boolean { + const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false +} + +/** The `this`-receiver exemption every function-like check shares. */ +function thisReceiver(p: ts.ParameterDeclaration): boolean { + return ts.isIdentifier(p.name) && p.name.text === 'this' +} + +/** + * True when a member name exists on any `extends`/`implements` heritage type + * of the class — the member implements or overrides a documented seam + * declaration, which is the doc's one home (the IDE inherits it on hover). + * Static members are looked up on the base CONSTRUCTOR type (only an + * `extends` expression has one; an unresolvable or interface expression + * yields no property and therefore no exemption). + * @param cls - the class whose heritage to search. + * @param name - the member name to look up. + * @param staticSide - whether to search the constructor side instead of the instance side. + * @param checker - the program's type checker. + * @returns true when a heritage type declares the member. + */ +function inheritedMember(cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker): boolean { + for (const clause of cls.heritageClauses ?? []) { + for (const t of clause.types) { + const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t) + if (type.getProperty(name) !== undefined) return true + } + } + return false +} + +/** + * Check description-prose presence for one labeled declaration: JSDoc must + * exist and carry prose above its block tags. + * @param where - the offender label violations open with. + * @param raw - the declaration's raw JSDoc block ('' if none). + * @param w - the walk state violations append to. + */ +function checkDescribed(where: string, raw: string, w: Walk): void { + if (!raw) w.violations.push(`${where} has no JSDoc.`) + else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`) +} + +/** + * Check the full function contract for one labeled function-like declaration: + * description prose, `@param` per parameter, `@returns` on a non-void result. + * @param where - the offender label violations open with. + * @param raw - the declaration's raw JSDoc block ('' if none). + * @param parameters - the declaration's parameter list. + * @param returnType - the return type annotation, or undefined when inferred. + * @param returnsWaived - suppress the `@returns`/annotation requirement (a + * declarator-annotated const defers its return contract to the named type). + * @param w - the walk state violations append to. + */ +function checkFunctionLike( + where: string, + raw: string, + parameters: readonly ts.ParameterDeclaration[], + returnType: ts.TypeNode | undefined, + returnsWaived: boolean, + w: Walk, +): void { + if (!raw) { w.violations.push(`${where} has no JSDoc.`); return } + if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`) + const { params, returns } = parseTags(raw) + checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations) + if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations) +} + +/** + * Check one exported class: class-level prose, the function contract on every + * public method (overload implementations exempt), and description prose on + * public properties and accessors (a get/set pair is covered by the getter's + * doc). Members declared by a heritage type and the plugin-protocol statics + * are exempt; constructors are not checked (framework-constructed plugins, + * and the class doc owns the story). + * @param cls - the exported class declaration. + * @param name - the class's surface name (namespace-qualified). + * @param w - the walk state violations append to. + */ +function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { + checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w) + const overloadSigs = new Set() + const documentedGetters = new Set() + for (const m of cls.members) { + if ('name' in m && ts.isComputedPropertyName(m.name)) continue + if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf)) + if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf)) + } + for (const m of cls.members) { + if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue + if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members + const mname = m.name.getText(w.sf) + if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot + if (inheritedMember(cls, mname, isStatic(m), w.checker)) continue // the heritage declaration owns the doc + if (ts.isMethodDeclaration(m)) { + if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs + checkFunctionLike(`exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), m.parameters, m.type, false, w) + } else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) { + const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor' + checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w) + } else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) { + checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w) + } + // index signatures / static blocks: not named surface + } +} + +/** + * Check one exported declaration statement, dispatching on its kind. + * @param stmt - the exported statement (export modifier or export-list target). + * @param prefix - the namespace qualification for surface names ('' at top level). + * @param overloadSigs - names in this scope declared as bodyless function overload signatures. + * @param byName - this scope's named declarations (for namespace/sibling-merge lookups). + * @param w - the walk state violations append to. + */ +function checkDecl( + stmt: ts.Statement, + prefix: string, + overloadSigs: Set, + byName: Map, + w: Walk, +): void { + const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})` + if (ts.isFunctionDeclaration(stmt)) { + const name = stmt.name?.text ?? 'default' + if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot + if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs + checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt), + stmt.parameters, stmt.type, false, w) + return + } + if (ts.isClassDeclaration(stmt)) { + checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w) + return + } + if (ts.isInterfaceDeclaration(stmt)) { + checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + return + } + if (ts.isTypeAliasDeclaration(stmt)) { + checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + return + } + if (ts.isEnumDeclaration(stmt)) { + checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + return + } + if (ts.isVariableStatement(stmt)) { + const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator + for (const d of stmt.declarationList.declarations) { + const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf) + if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot + const where = `exported const '${prefix}${name}'${at(d)}` + const init = d.initializer + if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { + // A declarator type annotation (`const f: Handler = …`) hands the + // return contract to the named type; the arrow's own annotation is + // still checked when it is the only signature the reader has. + checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w) + } else { + checkDescribed(where, raw, w) + } + } + return + } + if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) { + // A namespace merging with a documented same-name sibling (the + // Config-namespace idiom) needs no second doc block of its own. + const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt) + const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '') + if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + let body = stmt.body + let nsPrefix = `${prefix}${stmt.name.text}.` + while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B` + nsPrefix += `${body.name.getText(w.sf)}.` + body = body.body + } + if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w) + } +} + +/** + * Walk one lexical scope (file top level or a namespace body): check every + * exported declaration, resolving `export { … }` lists (no module specifier) + * to their local declarations. + * @param statements - the scope's statements. + * @param prefix - the namespace qualification for surface names ('' at top level). + * @param w - the walk state violations append to. + */ +function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk): void { + const byName = new Map() + const overloadSigs = new Set() + const add = (name: string, stmt: ts.Statement): void => { + byName.set(name, [...(byName.get(name) ?? []), stmt]) + } + for (const stmt of statements) { + if (ts.isFunctionDeclaration(stmt)) { + if (stmt.name) add(stmt.name.text, stmt) + if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text) + } else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt) + || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) { + if (stmt.name) add(stmt.name.text, stmt) + } else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) { + add(stmt.name.text, stmt) + } else if (ts.isVariableStatement(stmt)) { + for (const d of stmt.declarationList.declarations) { + if (ts.isIdentifier(d.name)) add(d.name.text, stmt) + } + } + } + const checked = new Set() + const check = (stmt: ts.Statement): void => { + if (checked.has(stmt)) return + checked.add(stmt) + checkDecl(stmt, prefix, overloadSigs, byName, w) + } + for (const stmt of statements) { + if (ts.isModuleDeclaration(stmt) + && (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) { + continue // `declare module '…'` / `declare global` augmentation: not an export of this package + } + if (ts.isExportDeclaration(stmt)) { + if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own + if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { + for (const el of stmt.exportClause.elements) { + for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl) + // a name with no local declaration is an imported binding re-exported + // without a specifier — its defining module is walked on its own + } + } + continue + } + if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { + if (ts.isIdentifier(stmt.expression)) { + for (const decl of byName.get(stmt.expression.text) ?? []) check(decl) + } else { + checkDescribed(`default export (${pointer(w.rel, w.sf, stmt)})`, rawJsDoc(w.text, stmt), w) + } + continue + } + if (isExported(stmt)) check(stmt) + } +} + +/** + * Compiler options for the walk's program. The real repo hands over its + * tsconfig.base.json (whose `paths` map resolves cross-package imports to + * source, so heritage-member lookups see seam types); a fixture root without + * one gets bare defaults — fixtures are single-file and self-contained. + * Emit-side options are stripped: the walk never emits or asks for + * diagnostics, it only binds types on demand. + * @param scanRoot - the root being scanned. + * @returns compiler options for ts.createProgram. + */ +function loadCompilerOptions(scanRoot: string): ts.CompilerOptions { + const cfgPath = resolve(scanRoot, 'tsconfig.base.json') + if (!existsSync(cfgPath)) return { skipLibCheck: true } + const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown } + const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot) + return { + ...parsed.options, + noEmit: true, + composite: false, + declaration: false, + declarationMap: false, + sourceMap: false, + incremental: false, + } +} + +/** + * Walk every non-vendored package source file and collect JSDoc-completeness + * violations for its module-level exports. Returns findings instead of + * throwing so tests assert on the list; the CLI entry turns a non-empty list + * into exit 1. + * @param scanRoot - the repo root to scan; tests pass a fixture dir. + * @returns every violation, in file order, one human-readable line each. + */ +export function collectExportJsdocViolations(scanRoot: string = root): string[] { + const violations: string[] = [] + const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort() + const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot)) + const checker = program.getTypeChecker() + for (const rel of rels) { + const sf = program.getSourceFile(resolve(scanRoot, rel)) + if (!sf) continue // program root files always resolve; guard for narrowing + checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }) + } + return violations +} + +/** CLI entry: list every violation and exit 1, or confirm a clean surface. */ +function main(): void { + const violations = collectExportJsdocViolations() + if (violations.length === 0) { + console.log('verify-export-jsdoc: every exported name on the package surface is documented.') + return + } + console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`) + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} From 9ff010cbef92569597058ae46f72917463852396 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:25:33 +0800 Subject: [PATCH 04/36] fix review findings: close export-form and heritage-exemption gaps Codex round-1 review found three fail-open paths in the new gate: - Unhandled export forms passed silently. checkDecl now fails CLOSED on unrecognized exported statement kinds, 'export =' is refused outright, 'export import X = N.member' is an explicit documented skip (alias; definition site owns the doc), and ambient 'declare namespace' bodies recurse with implicit export semantics. - Function-like exports escaped the function contract: non-identifier default exports and consts with INLINE function-type annotations now get full @param/@returns checks (the named-type waiver stays for reference annotations only). - The heritage exemption was name-only: it no longer exempts a public override of a protected-only base member, and parameters the base never names keep their @param duty (underscore-prefixed renames of a base parameter count as the same parameter). Eight new negative-path tests pin the closed gaps; RFC and module doc updated to the refined contract. --- .../2026-07-06-export-surface-jsdoc-gate.md | 9 +- .../agent/tests/verify-export-jsdoc.spec.ts | 100 +++++++++ scripts/verify-export-jsdoc.ts | 193 +++++++++++++----- 3 files changed, 249 insertions(+), 53 deletions(-) diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index fa92fbcb3d..f3e6ab45c5 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -13,15 +13,16 @@ A new gate, `scripts/verify-export-jsdoc.ts` (`pnpm run verify-export-jsdoc`, wi The contract by declaration kind: - Every exported name needs JSDoc with non-empty description prose. -- Function-like exports (function declarations; consts with function initializers) follow the full function contract. A const whose declarator is type-annotated (`export const f: Handler = …`) defers the return contract to the named type; `@returns` stays optional there. +- Function-like exports (function declarations; consts with function initializers or an INLINE function-type annotation; non-identifier function default exports) follow the full function contract. A const whose declarator is annotated with a NAMED type (`export const f: Handler = …`) defers the signature contract to that type's own declaration and `@returns` stays optional; an inline `(x: T) => U` annotation is the surface signature itself and gets the full contract. - Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs. - Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate). -- Exported namespaces recurse; the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once). -- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. +- Exported namespaces recurse (inside an ambient `declare` namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once). +- `declare module` / `declare global` bodies, `export … from` re-export statements, and `export import X = N.member` aliases are skipped: an augmentation is not an export of the package, and a re-exported or aliased definition is checked where it is defined. +- Everything else fails CLOSED: `export =` is refused outright, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission. Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): -- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. This is the one question the walk asks the TYPE CHECKER (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). +- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where the override grows surface the base never documented: a protected-only base member does not exempt a public override, and parameters the base never names keep their `@param` duty (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter). This is the one question the walk asks the TYPE CHECKER (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). - **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics. - **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story. diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index 89439a3a59..6682142abf 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -294,3 +294,103 @@ export namespace Loose { ]) }) }) + +describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { + it('checks the function contract on a non-identifier default export', () => { + expect(collectExportJsdocViolations(make( + '/** Doubles. */\nexport default (x: number): number => x * 2\n', + ))).toEqual([ + expect.stringMatching(/default export .* is missing @param x\./), + expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./), + ]) + expect(collectExportJsdocViolations(make( + '/**\n * Doubles.\n * @param x - the input.\n * @returns twice the input.\n */\nexport default (x: number): number => x * 2\n', + ))).toEqual([]) + }) + + it('treats an inline function-type annotation as the surface signature', () => { + expect(collectExportJsdocViolations(make( + '/** Maps a number. */\nexport declare const f: (x: number) => number\n', + ))).toEqual([ + expect.stringMatching(/exported const 'f' .* is missing @param x\./), + expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./), + ]) + expect(collectExportJsdocViolations(make( + '/**\n * Maps a number.\n * @param x - the input.\n * @returns the mapped value.\n */\nexport const f: (x: number) => number = v => v\n', + ))).toEqual([]) + }) + + it('recurses into an ambient declare namespace where members export implicitly', () => { + expect(collectExportJsdocViolations(make( + 'export declare namespace N {\n function f(x: number): number\n}\n', + ))).toEqual([ + expect.stringMatching(/exported namespace 'N' .* has no JSDoc\./), + expect.stringMatching(/exported function 'N.f' .* has no JSDoc\./), + ]) + }) + + it('skips an export-import alias (the aliased definition owns the doc)', () => { + expect(collectExportJsdocViolations(make( + '/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n', + ))).toEqual([]) + }) + + it('refuses an export-equals assignment instead of failing open', () => { + expect(collectExportJsdocViolations(make( + 'const x = 1\nexport = x\n', + ))).toEqual([expect.stringMatching(/export-equals assignment .* is not a gate-supported export form/)]) + }) +}) + +describe('verify-export-jsdoc heritage refinement (review round 1)', () => { + it('requires @param for parameters the base member never names', () => { + const violations = collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** + * Do it. + * @param x - input. + * @returns output. + */ + abstract run(x: number): number +} +/** Impl. */ +export class Impl extends Base { + override run(x: number, verbose?: boolean): number { return verbose ? x : -x } +} +`)) + expect(violations).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @param verbose\./)]) + }) + + it('does not exempt a public override of a protected-only base member', () => { + expect(collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** Subclass hook. */ + protected hook(): void {} +} +/** Impl. */ +export class Impl extends Base { + override hook(): void {} +} +`))).toEqual([expect.stringMatching(/exported class method 'Impl.hook' .* has no JSDoc\./)]) + }) + + it('treats an underscore-prefixed rename of a base parameter as the same parameter', () => { + expect(collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** + * Load it. + * @param cwd - the working directory to scope the lookup. + * @returns the loaded value. + */ + abstract load(cwd: string): number +} +/** Impl (ignores cwd). */ +export class Impl extends Base { + load(_cwd: string): number { return 1 } +} +`))).toEqual([]) + }) +}) diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 2779a53e68..5a8a28af1a 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -13,33 +13,39 @@ * * - Every exported name needs JSDoc with non-empty description prose (prose * ends at the first block tag, standard JSDoc semantics). - * - A function-like export (function declaration, or a const with a function - * initializer) additionally needs a non-empty `@param` per parameter - * (`this` receiver annotations exempt; a stale `@param` errors) and a - * non-empty `@returns` unless the return type is `void`/`Promise`. - * The walk classifies returns syntactically, so the return type must be - * ANNOTATED — except a const whose DECLARATOR is type-annotated (e.g. - * `export const f: Handler = …`), where the named type owns the return - * contract and `@returns` stays optional. + * - A function-like export (function declaration, a const with a function + * initializer or an INLINE function-type annotation, or a non-identifier + * function default export) additionally needs a non-empty `@param` per + * parameter (`this` receiver annotations exempt; a stale `@param` errors) + * and a non-empty `@returns` unless the return type is `void` / + * `Promise`. The walk classifies returns syntactically, so the return + * type must be ANNOTATED — except a const whose declarator is annotated + * with a NAMED type (e.g. `export const f: Handler = …`), where that type's + * own declaration owns the signature contract and `@returns` stays + * optional; an inline `(x: T) => U` annotation is the surface signature + * itself and gets the full contract. * - An exported class needs class-level JSDoc; its public methods (static * included — they are reachable on the exported name) follow the function * contract, and public properties and accessors need description prose (on - * a get/set pair the getter's doc covers both). A member whose name exists - * on an `extends`/`implements` heritage type is EXEMPT — the seam - * declaration is the doc's one home, the IDE inherits it, and re-documenting - * every implementation invites drift. This is the one question the walk - * asks the TYPE CHECKER (heritage members live across package boundaries); - * everything else is pure AST. Constructors are exempt like the cordis - * gate's: plugin classes are framework-constructed, and the class doc owns - * the story. + * a get/set pair the getter's doc covers both). A member declared by an + * `extends`/`implements` heritage type is EXEMPT — the seam declaration is + * the doc's one home, the IDE inherits it, and re-documenting every + * implementation invites drift — UNLESS the override grows surface the + * base never documented: a protected-only base member does not exempt a + * public override, and parameters the base never names keep their `@param` + * duty. This is the one question the walk asks the TYPE CHECKER (heritage + * members live across package boundaries); everything else is pure AST. + * Constructors are exempt like the cordis gate's: plugin classes are + * framework-constructed, and the class doc owns the story. * - Exported interfaces, type aliases, enums: description prose on the * declaration (member-level docs stay review's job; the highest-value * member surface — seam service classes — is already under the cordis * gate). * - An exported namespace recurses (its exported members are package - * surface); the namespace itself needs prose only when it does not merge - * with an already-documented same-name declaration (the Config-namespace - * idiom documents the class/function once, not twice). + * surface; in an ambient `declare` namespace every member exports + * implicitly); the namespace itself needs prose only when it does not + * merge with an already-documented same-name declaration (the + * Config-namespace idiom documents the class/function once, not twice). * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject` * / `reusable` / `Config` consts and the `apply` entry, plus the same * slots as statics on a plugin class. Their shape is fixed by the @@ -50,10 +56,13 @@ * - Overload groups: each overload signature carries its own docs; the * implementation signature is exempt (callers never see it). * - Skipped: `declare module` / `declare global` augmentation bodies (the - * cordis gate's turf; an augmentation is not an export of the package) and - * re-export statements with a module specifier (`export … from`) — the - * defining module is walked on its own, and external definitions are not - * ours to document. + * cordis gate's turf; an augmentation is not an export of the package), + * re-export statements with a module specifier (`export … from`) and + * `export import X = N.member` aliases — the defining module is walked on + * its own, and external definitions are not ours to document. + * - Everything else fails CLOSED: `export =` is refused outright, and an + * exported statement kind the dispatch does not recognize is itself a + * violation, so no export form can pass unchecked by omission. */ import { existsSync, globSync } from 'node:fs' @@ -107,26 +116,60 @@ function thisReceiver(p: ts.ParameterDeclaration): boolean { } /** - * True when a member name exists on any `extends`/`implements` heritage type - * of the class — the member implements or overrides a documented seam - * declaration, which is the doc's one home (the IDE inherits it on hover). - * Static members are looked up on the base CONSTRUCTOR type (only an - * `extends` expression has one; an unresolvable or interface expression - * yields no property and therefore no exemption). + * The heritage-member exemption for one class member. When the member's name + * is declared by an `extends`/`implements` heritage type, the seam declaration + * is the doc's one home (the IDE inherits it on hover) and the member needs no + * doc of its own — EXCEPT where the override grows public surface the base + * never documented: a base member that is protected on every declaration does + * not exempt a public override (consumers could not call it before), and + * parameters the base never names keep their own `@param` duty (the caller + * reads the seam doc, which cannot describe them; an underscore-prefixed + * rename of a base parameter — the deliberately-unused marker — is the same + * parameter, not new surface). Static members are looked + * up on the base CONSTRUCTOR type (only an `extends` expression has one; an + * unresolvable or interface expression yields no property and therefore no + * exemption). * @param cls - the class whose heritage to search. * @param name - the member name to look up. * @param staticSide - whether to search the constructor side instead of the instance side. * @param checker - the program's type checker. - * @returns true when a heritage type declares the member. + * @returns null when no exemption applies; otherwise the parameter names the + * base declarations carry (`baseParams: null` means the base's parameters are + * not syntactically recoverable — a complex heritage type — and the member is + * exempt in full). */ -function inheritedMember(cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker): boolean { +function heritageExemption( + cls: ts.ClassDeclaration, + name: string, + staticSide: boolean, + checker: ts.TypeChecker, +): { baseParams: Set | null } | null { + const isProtected = (d: ts.Declaration): boolean => + (ts.canHaveModifiers(d) ? ts.getModifiers(d) : undefined)?.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false for (const clause of cls.heritageClauses ?? []) { for (const t of clause.types) { const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t) - if (type.getProperty(name) !== undefined) return true + const prop = type.getProperty(name) + if (prop === undefined) continue + const decls = prop.declarations ?? [] + if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface + let baseParams: Set | null = null + for (const d of decls) { + let params: readonly ts.ParameterDeclaration[] | undefined + if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) params = d.parameters + else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) { + params = d.type.parameters + } else continue + baseParams ??= new Set() + // Leading underscores are the deliberately-unused marker (eslint + // argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the + // same parameter, so compare underscore-stripped on both sides. + for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, '')) + } + return { baseParams } } } - return false + return null } /** @@ -171,9 +214,10 @@ function checkFunctionLike( * Check one exported class: class-level prose, the function contract on every * public method (overload implementations exempt), and description prose on * public properties and accessors (a get/set pair is covered by the getter's - * doc). Members declared by a heritage type and the plugin-protocol statics - * are exempt; constructors are not checked (framework-constructed plugins, - * and the class doc owns the story). + * doc). Heritage-declared members are exempt per heritageExemption (an + * override's extra parameters keep their @param duty); plugin-protocol + * statics are exempt; constructors are not checked (framework-constructed + * plugins, and the class doc owns the story). * @param cls - the exported class declaration. * @param name - the class's surface name (namespace-qualified). * @param w - the walk state violations append to. @@ -192,10 +236,26 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members const mname = m.name.getText(w.sf) if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot - if (inheritedMember(cls, mname, isStatic(m), w.checker)) continue // the heritage declaration owns the doc + const exemption = heritageExemption(cls, mname, isStatic(m), w.checker) if (ts.isMethodDeclaration(m)) { if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs - checkFunctionLike(`exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), m.parameters, m.type, false, w) + const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})` + if (exemption !== null) { + // The heritage declaration owns prose and @returns; parameters the + // base never names are new surface and keep their @param duty. + const base = exemption.baseParams + const inBase = (p: ts.ParameterDeclaration): boolean => + base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, '')) + if (base !== null && m.parameters.some(p => ts.isIdentifier(p.name) && p.name.text !== 'this' && !inBase(p))) { + const { params } = parseTags(rawJsDoc(w.text, m)) + checkParams(where, 'export', m.parameters, params, w.sf, + p => thisReceiver(p) || inBase(p), w.violations) + } + continue + } + checkFunctionLike(where, rawJsDoc(w.text, m), m.parameters, m.type, false, w) + } else if (exemption !== null) { + continue // the heritage declaration owns the doc (properties/accessors carry no own parameters) } else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) { const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor' checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w) @@ -207,11 +267,14 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { } /** - * Check one exported declaration statement, dispatching on its kind. + * Check one exported declaration statement, dispatching on its kind. Any + * exported statement kind the dispatch does not recognize is a violation + * (fail closed), so no export form can pass unchecked by omission. * @param stmt - the exported statement (export modifier or export-list target). * @param prefix - the namespace qualification for surface names ('' at top level). * @param overloadSigs - names in this scope declared as bodyless function overload signatures. * @param byName - this scope's named declarations (for namespace/sibling-merge lookups). + * @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly. * @param w - the walk state violations append to. */ function checkDecl( @@ -219,6 +282,7 @@ function checkDecl( prefix: string, overloadSigs: Set, byName: Map, + ambient: boolean, w: Walk, ): void { const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})` @@ -253,9 +317,14 @@ function checkDecl( if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot const where = `exported const '${prefix}${name}'${at(d)}` const init = d.initializer - if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { - // A declarator type annotation (`const f: Handler = …`) hands the - // return contract to the named type; the arrow's own annotation is + if (d.type !== undefined && ts.isFunctionTypeNode(d.type)) { + // An INLINE function-type annotation is the surface signature itself: + // its parameters and result need docs right here. (A NAMED reference + // type carries its docs at the type's own declaration instead.) + checkFunctionLike(where, raw, d.type.parameters, d.type.type, false, w) + } else if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { + // A named declarator type annotation (`const f: Handler = …`) hands + // the return contract to the named type; the arrow's own annotation is // still checked when it is the only signature the reader has. checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w) } else { @@ -276,8 +345,21 @@ function checkDecl( nsPrefix += `${body.name.getText(w.sf)}.` body = body.body } - if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w) + // In an ambient (`declare`) namespace body, members are implicitly + // exported — no `export` modifier required — so the recursion must treat + // every statement as surface. + const declared = ambient + || ((ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false) + if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w, declared) + return } + if (ts.isImportEqualsDeclaration(stmt)) { + return // alias re-export (`export import X = N.member`): the aliased definition owns the doc, like `export … from` + } + // Fail CLOSED: an exported statement kind this dispatch does not recognize + // must never pass silently — the gate's whole promise is that unchecked + // surface cannot exist. New TypeScript export forms extend the gate here. + w.violations.push(`exported statement${at(stmt)} uses an export form verify-export-jsdoc does not handle; extend the gate.`) } /** @@ -287,8 +369,9 @@ function checkDecl( * @param statements - the scope's statements. * @param prefix - the namespace qualification for surface names ('' at top level). * @param w - the walk state violations append to. + * @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly. */ -function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk): void { +function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void { const byName = new Map() const overloadSigs = new Set() const add = (name: string, stmt: ts.Statement): void => { @@ -313,7 +396,7 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk const check = (stmt: ts.Statement): void => { if (checked.has(stmt)) return checked.add(stmt) - checkDecl(stmt, prefix, overloadSigs, byName, w) + checkDecl(stmt, prefix, overloadSigs, byName, ambient, w) } for (const stmt of statements) { if (ts.isModuleDeclaration(stmt) @@ -331,15 +414,25 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk } continue } - if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { + if (ts.isExportAssignment(stmt)) { + if (stmt.isExportEquals) { + // `export =` has no ESM consumer surface in this repo and the walk + // cannot classify its operand's shape; refuse rather than fail open. + w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`) + continue + } + const where = `default export (${pointer(w.rel, w.sf, stmt)})` if (ts.isIdentifier(stmt.expression)) { for (const decl of byName.get(stmt.expression.text) ?? []) check(decl) + } else if (ts.isArrowFunction(stmt.expression) || ts.isFunctionExpression(stmt.expression)) { + const fn = stmt.expression + checkFunctionLike(where, rawJsDoc(w.text, stmt), fn.parameters, fn.type, false, w) } else { - checkDescribed(`default export (${pointer(w.rel, w.sf, stmt)})`, rawJsDoc(w.text, stmt), w) + checkDescribed(where, rawJsDoc(w.text, stmt), w) } continue } - if (isExported(stmt)) check(stmt) + if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) check(stmt) } } @@ -385,7 +478,9 @@ export function collectExportJsdocViolations(scanRoot: string = root): string[] for (const rel of rels) { const sf = program.getSourceFile(resolve(scanRoot, rel)) if (!sf) continue // program root files always resolve; guard for narrowing - checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }) + // A script-style declaration file (no imports/exports) is one big ambient + // scope; a module-style .d.ts still honors explicit export modifiers. + checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf)) } return violations } From 74502fa8c2e92bc85f26ec1ec7d259282609da8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:29:08 +0800 Subject: [PATCH 05/36] Structured output on the subagent seam: schema subset, capture runtime, spawn/fork support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carved out of #170 per review feedback — the foundation the workflow tool builds on, now standing alone on master: - dsh-tools: the structured-output JSON Schema subset (StructuredOutputSchema, assertSupportedOutputSchema, validateStructuredValue) — rejects loud outside the enforced subset, listing every violation - dsh-subagent: SubagentStartRequest.outputSchema / SubagentResult.structured become a real capability; the service rejects a schema'd request whose provider lacks it - dsh-subagent-inprocess: the shared structured runtime — one global structured_output capture tool, a prepend final-assembly listener that strips the placeholder for plain agents and swaps in the run's own schema (plus the calling instruction as a trailing section) for structured children, an agent/turn-continuation veto once captured, and the capture/nudge loop in the run driver (structuredNudgeRetries, cancellation honored mid-nudge); lifetime refcounted by backends and live runs - subagent-spawn / subagent-fork flip outputSchema: true One deliberate divergence from the #170 revision: the backends do NOT add 'tools' to their plugin inject. Doing so deferred their apply past the todo plugin, and the delegation tool mirrors provider lifecycle — so the model-visible tool order of every existing prompt changed, invalidating every recorded snapshot fixture. The runtime now gates its capture-tool registration on tools availability itself (sync when live, a scoped inject fiber when the Loader starts the backend first), keeping this PR byte-invisible to existing transcripts: all 35 snapshot scenarios pass against master's fixtures unchanged. --- docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 4 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 4 +- packages/core/tools/README.md | 6 + packages/core/tools/src/index.ts | 10 + packages/core/tools/src/json-schema.ts | 322 ++++++++++++ packages/core/tools/tests/json-schema.spec.ts | 254 +++++++++ packages/subagent/subagent-fork/README.md | 3 +- packages/subagent/subagent-fork/src/index.ts | 37 +- .../tests/multi-subagent.spec.ts | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 14 +- .../subagent/subagent-inprocess/README.md | 21 +- .../subagent/subagent-inprocess/package.json | 4 + .../subagent/subagent-inprocess/src/index.ts | 99 +++- .../subagent-inprocess/src/structured.ts | 239 +++++++++ .../tests/structured.spec.ts | 485 ++++++++++++++++++ .../tests/subagent-inprocess.spec.ts | 6 +- .../subagent/subagent-inprocess/tsconfig.json | 6 + packages/subagent/subagent-spawn/README.md | 5 +- packages/subagent/subagent-spawn/src/index.ts | 51 +- .../subagent/subagent-spawn/tests/harness.ts | 2 +- .../tests/subagent-spawn.spec.ts | 14 +- packages/subagent/subagent/src/types.ts | 14 +- .../subagent/subagent/tests/service.spec.ts | 4 +- .../subagent-mock/tests/subagent-mock.spec.ts | 4 +- pnpm-lock.yaml | 6 + 28 files changed, 1570 insertions(+), 62 deletions(-) create mode 100644 packages/core/tools/src/json-schema.ts create mode 100644 packages/core/tools/tests/json-schema.spec.ts create mode 100644 packages/subagent/subagent-inprocess/src/structured.ts create mode 100644 packages/subagent/subagent-inprocess/tests/structured.spec.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 753cf8f4d0..0f7ed1dc68 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -307,7 +307,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 @@ -319,7 +319,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 @@ -331,7 +331,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) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0221946118..58186533fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -204,7 +204,7 @@ async execute(exec: ToolExecution): Promise 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` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 926e6ce9b8..f21ef15669 100644 --- a/docs/core-data-structures/subagent.md +++ b/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[] } } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24a2793826..a77015ceda 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,8 +31,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`. diff --git a/docs/module-graph.md b/docs/module-graph.md index ed1cc592d4..8283a8b756 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 65039aea58..d43f24e123 100644 --- a/packages/core/tools/README.md +++ b/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`): diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index dd0ed918db..39dafd6f1a 100644 --- a/packages/core/tools/src/index.ts +++ b/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). diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts new file mode 100644 index 0000000000..35eeb240a4 --- /dev/null +++ b/packages/core/tools/src/json-schema.ts @@ -0,0 +1,322 @@ +/** + * 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 + /** 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 non-null, non-array object (structural, realm-agnostic). */ +function isObjectLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 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): 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)) + 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): 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 = { + 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 : {} + for (const key of required) { + if (!(key in declared)) 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). + */ +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 ?? {} + for (const key of node.required ?? []) { + if (value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) + } + for (const [key, child] of Object.entries(properties)) { + if (value[key] === undefined) continue + violations.push(...checkValue(child, value[key], `${path}.${key}`)) + } + if (node.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in properties)) 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') +} diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts new file mode 100644 index 0000000000..e7635b06f3 --- /dev/null +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -0,0 +1,254 @@ +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 = {} + 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 = { 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 } }) + }) +}) + +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('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/) + }) +}) diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index c691d56355..7b43f82261 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -12,12 +12,13 @@ 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 | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `fork`). | +| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index b6d0c10e44..10d0492198 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -25,19 +25,29 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' +// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the +// 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. */ +/** Config: the registry name to register the provider under, plus structured-run tuning. */ export interface Config { /** Provider name on `ctx.subagents` (default `fork`). */ providerName: string + /** + * How many times a structured run re-prompts a child that finished cleanly + * without calling `structured_output` before giving up (default 1). + */ + structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('fork'), + structuredNudgeRetries: z.natural().default(1), }) /** @@ -57,20 +67,26 @@ 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 - constructor(readonly name: string, private readonly ctx: Context) {} + constructor( + readonly name: string, + private readonly ctx: Context, + private readonly structuredNudgeRetries: number, + ) {} start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(this.ctx, request, { providerName: this.name, + structuredNudgeRetries: this.structuredNudgeRetries, // Only pass a seed when there's a completed turn to inherit; an empty seed // is equivalent to a fresh child, so omit it to keep the session unseeded. ...seed.length > 0 ? { seed } : {}, @@ -79,5 +95,12 @@ class ForkProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) + // Hold the structured runtime for the plugin's lifetime (see the spawn + // backend — same two-level lifetime: backends for availability, runs for + // mid-run survival across a backend unload). + ctx.effect(() => { + const acquisition = acquireStructuredRuntime(ctx) + return () => { acquisition.release() } + }, 'subagent-fork structured runtime') + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries)) } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 1f932fbaf9..82caf25948 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -30,8 +30,8 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn' }) - await ctx.plugin(fork, { providerName: 'fork' }) + await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 56441a656f..2545188b52 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -37,7 +37,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(fork, { providerName: 'fork' }) + await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } @@ -161,16 +161,22 @@ 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 () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(fork, { providerName: 'fork' }) + // The backend does NOT inject 'tools' (the structured runtime gates its + // capture-tool registration on tools availability itself, keeping backend + // apply timing — and the delegation tool's prompt position — unchanged); + // the registries are loaded here so the runtime registers eagerly anyway. + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) expect(ctx.subagents.list()).toEqual(['fork']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index af6d5792a9..b816da8946 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,16 +8,27 @@ 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)) before any child exists; +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); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times; +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`. ### `InProcessRunOptions` -`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. +`{ providerName: string; seed?: SessionEvent[]; structuredNudgeRetries: number }` — the per-backend inputs: the provider name (for error context), the optional child-session seed, and the structured-run nudge budget (REQUIRED, resolved from the backend's validated Config — the driver never fills it with a hidden default). + +### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition` + +The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder: + +- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire 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 appended to its `system` text (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request. +- 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 records the value. + +Lifetime is refcounted with two kinds of holder: each backend acquires for its plugin lifetime (`apply`), and each structured RUN holds its own acquisition from start to settle — so unregistration can never precede a live run's settle, and the runtime disposes only when the last backend AND the last run are gone. `release()` is idempotent per acquisition. ### `depthOf(agent): number` diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index f3bd774554..ecc177162f 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/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": { @@ -35,6 +37,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1107926aa2..381f94acba 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -18,7 +18,21 @@ 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, + STRUCTURED_OUTPUT_NUDGE, + type StructuredAcquisition, +} from './structured.ts' + +export { + acquireStructuredRuntime, + STRUCTURED_OUTPUT_TOOL, + STRUCTURED_OUTPUT_INSTRUCTION, + STRUCTURED_OUTPUT_NUDGE, + type StructuredAcquisition, +} from './structured.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -76,6 +90,13 @@ export interface InProcessRunOptions { * parent's log (FORK), or `undefined` for a fresh child (SPAWN). */ readonly seed?: SessionEvent[] + /** + * How many times a structured run re-prompts a child that finished a turn + * cleanly WITHOUT calling `structured_output` (see the structured module). + * REQUIRED, resolved from the backend's validated Config — per the explicit- + * defaulting rule, the driver never fills it with a hidden fallback. + */ + readonly structuredNudgeRetries: number } /** @@ -98,6 +119,10 @@ export function startInProcessRun( if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } + // Assert the schema subset BEFORE any child exists (the service has already + // capability-gated; this rejects a schema outside the enforced subset loud). + const schema = request.outputSchema + if (schema !== undefined) assertSupportedOutputSchema(schema) const childId = AgentId(randomUUID()) // The child's OWN events begin after the seed (fork seeds the parent's @@ -109,13 +134,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()), @@ -130,6 +162,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). @@ -138,6 +171,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 `!cancelled` in the nudge condition reads as always-true. + const isCancelled = (): boolean => cancelled const requestCancel = (reason: string): void => { cancelled = true child.cancel(reason) @@ -154,9 +191,35 @@ export function startInProcessRun( if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } child.send(request.prompt) await child.whenIdle() - return readResult(child, seedLength, cancelled) + if (structured) { + // Nudge loop: a child that finished a turn CLEANLY without calling + // structured_output gets re-prompted, up to the backend-configured + // retry count. An errored/aborted turn is not nudged — its failure is + // the honest result (a cancelled turn ends `aborted`, and a pre-turn + // cancel leaves no `turn/end` at all, so neither reads `completed`). + // `!cancelled` closes the remaining window: a cancel landing AFTER a + // clean turn end clears nothing — `child.cancel()` only kills + // queued/running work — so without it the next `send` would spend a + // fresh post-cancellation turn; the condition re-evaluates after + // every `whenIdle()`, so a mid-nudge cancel stops the loop at the + // next boundary too. + let nudges = options.structuredNudgeRetries + while ( + !isCancelled() && structured.captured(child) === undefined && nudges > 0 + && lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed' + ) { + nudges -= 1 + child.send([{ type: 'text', text: STRUCTURED_OUTPUT_NUDGE }]) + await child.whenIdle() + } + } + return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined) } finally { request.signal?.removeEventListener('abort', onAbort) + if (structured) { + structured.detach(child) + structured.release() + } } })() @@ -173,6 +236,12 @@ export function startInProcessRun( } } +/** The child's OWN last `turn/end` event (events at or after `seedLength`), if any. */ +function lastOwnTurnEnd(child: Agent, seedLength: number): SessionEvent<'turn/end'> | undefined { + return child.session.events.slice(seedLength) + .findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') +} + /** * Read a settled child's terminal result from its session log, scoped to the * child's OWN events (everything at or after `seedLength` — fork seeds the @@ -184,12 +253,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 } } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts new file mode 100644 index 0000000000..69e4ea4fd6 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -0,0 +1,239 @@ +/** + * 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.) + * + * 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. + * + * Lifetime is refcounted with two kinds of holder: each backend acquires for + * its plugin lifetime (so the tool exists before any run), and each structured + * RUN acquires from start to settle (so a backend hot-reload mid-run cannot + * unregister the capture tool out from under a live child). Registrations are + * effects on the ROOT context — their natural upper bound is app teardown — and + * the refcount disposes them when the last holder releases. + * + * @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 { ToolExecution } 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.' + +/** The nudge sent when a structured child finishes cleanly without calling the tool. */ +export const STRUCTURED_OUTPUT_NUDGE + = `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. ` + + `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.` + +/** One structured run's state: the schema to enforce and the captured value, once recorded. */ +interface RunState { + readonly schema: StructuredOutputSchema + captured?: { value: unknown } +} + +/** The per-root-context runtime: run states plus the shared registrations. */ +interface StructuredRuntime { + refs: number + readonly states: WeakMap + readonly disposers: (() => void)[] +} + +/** One root context ⇒ one runtime (multi-app test isolation). */ +const runtimes = new WeakMap() + +/** + * 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 two waterfall 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 { + 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) + state.captured = { 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, + ): Promise { + 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, + } + 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, + ): Promise { + if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) + return next() + }, { prepend: true })) +} diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts new file mode 100644 index 0000000000..6982f7cde5 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { 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 * as spawn from '@deepseek-ai/dsh-subagent-spawn' +import * as fork from '@deepseek-ai/dsh-subagent-fork' +import { + acquireStructuredRuntime, + STRUCTURED_OUTPUT_INSTRUCTION, + STRUCTURED_OUTPUT_TOOL, +} from '../src/structured.ts' + +type Script = ConstructorParameters[0] + +const SCHEMA: StructuredOutputSchema = { + type: 'object', + properties: { answer: { type: 'number' }, note: { type: 'string' } }, + required: ['answer'], +} + +/** + * Real loop + scripted mock model + the REAL spawn backend (which acquires the + * structured runtime at apply, exactly as shipped). The mock model script + * drives the child's structured_output calls. + */ +async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) { + 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 fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 }) + const forkFiber = options?.withFork + ? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 }) + : undefined + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter, fiber, forkFiber } +} + +function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial): 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('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({ 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({ 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('nudges a child that finished cleanly without calling the tool, then captures', async () => { + const { ctx, parent } = await setup([ + textResponse('here is my answer in prose'), + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 3 }) + expect(result.stopReason).toBe('completed') + // The nudge is a real user-visible message in the child's log. + const child = ctx.agents.get(run.id)! + const users = child.session.events.filter(e => e.type === 'user/message') + expect(users.length).toBe(2) + await run.dispose() + }) + + it('settles error when the nudges run out without a capture', async () => { + const { ctx, parent, adapter } = await setup([ + textResponse('prose only'), + textResponse('still prose'), + ], { nudges: 1 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(result.structured).toBeUndefined() + expect(adapter.requests.length).toBe(2) + await run.dispose() + }) + + it('zero nudge retries fails immediately after the first clean prose finish', async () => { + const { ctx, parent, adapter } = await setup([textResponse('prose')], { nudges: 0 }) + 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 child that errored is NOT nudged (its failure is the honest result)', async () => { + // Script exhaustion on the first call → the child turn errors. + const { ctx, parent, adapter } = await setup([], { nudges: 3 }) + 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 turn end stops the nudge loop: no post-cancellation turn is spent', async () => { + const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // Cancel synchronously inside the first turn's end recording — after the + // turn reads `completed`, before the nudge continuation resumes. The turn + // state alone cannot see this cancel (`child.cancel()` only clears + // queued/running work), so without the loop's own cancelled check the + // next send would spend a fresh child turn after the caller cancelled. + ctx.on('session/event', (session, event) => { + if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge') + }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + // Exactly one model request: the nudge turn never ran. + expect(adapter.requests.length).toBe(1) + 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('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 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) + ? { 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) + ? { 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 bare = await ctx.systemPrompt.assemble({}) + expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL) + + const acquisition = acquireStructuredRuntime(ctx) + 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: backends + live runs)', () => { + it('registers the capture tool while a backend is loaded and unregisters when the last unloads', async () => { + const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + await fiber.dispose() + // fork still holds a reference. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + await forkFiber!.dispose() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + }) + + it('a live run-level acquisition keeps the runtime registered after EVERY backend unloads', async () => { + // Simulates the run-holder half of the two-level lifetime: a structured + // run acquires at start and releases at settle, so registration ordering + // is settle-then-unregister even if all backends unload first. (A real + // in-process child dies WITH its backend's fiber — the acquisition's + // observable job is this ordering, which a manual holder pins directly.) + const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) + const runHolder = acquireStructuredRuntime(ctx) + await fiber.dispose() + await forkFiber!.dispose() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + runHolder.release() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + }) + + it('a structured run releases its acquisition when it settles (backend unload mid-run)', async () => { + const { ctx, parent, fiber } = await setup(['hang']) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + // 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 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') + // Both holders (backend + run) released — nothing keeps the runtime now. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + await run.dispose() + }) + + it('fork children capture structured output through the same runtime', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), + ], { withFork: true }) + const run = ctx.subagents.start('fork', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 9 }) + await run.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() + // The backend still holds its own reference from setup(). + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + }) + }) + + it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => { + const { ctx, parent } = await setup([]) + 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(result.content[0]).toMatchObject({ type: 'text' }) + }) + + it('a structured_output call with NO calling agent at all is an isError', async () => { + const { ctx } = await setup([]) + const result = await ctx.tools.execute({ + callId: 'x' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + }) + expect(result.isError).toBe(true) + }) +}) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 7219e03988..d3870ae51a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -51,7 +51,7 @@ describe('depthOf', () => { describe('startInProcessRun', () => { it('drives a fresh child (no seed) to completion and returns its output', async () => { const { ctx, parent } = await setup([textResponse('driver child answer')]) - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver child answer') @@ -61,7 +61,7 @@ describe('startInProcessRun', () => { it('throws SubagentDepthError when the child would exceed maxDepth', async () => { const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 })) .toThrow(SubagentDepthError) }) @@ -73,7 +73,7 @@ describe('startInProcessRun', () => { parent.send([{ type: 'text', text: 'parent q' }]) await parent.whenIdle() const seed = parent.session.events.slice() - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('seeded child reply') diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 4cb435d4fb..7b7a015cc9 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -25,6 +25,12 @@ }, { "path": "../subagent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 97dfae9304..059c996215 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,14 +6,15 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName, structuredNudgeRetries })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## 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 shared [structured runtime](../subagent-inprocess/README.md) (the backend acquires it for its plugin lifetime; each structured run holds its own acquisition until it settles). Tool-scoping is deferred (the service rejects a request needing it before `start` runs). ## Config | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | +| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index e6cf5039a7..248e887ce7 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/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 @@ -17,40 +22,68 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' +// `tools` is deliberately NOT injected: the structured runtime gates its own +// capture-tool registration on `tools` availability internally, 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. */ +/** Config: the registry name to register the provider under, plus structured-run tuning. */ export interface Config { /** Provider name on `ctx.subagents` (default `spawn`). */ providerName: string + /** + * How many times a structured run re-prompts a child that finished cleanly + * without calling `structured_output` before giving up (default 1). + */ + structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('spawn'), + structuredNudgeRetries: z.natural().default(1), }) /** * 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 - constructor(readonly name: string, private readonly ctx: Context) {} + constructor( + readonly name: string, + private readonly ctx: Context, + private readonly structuredNudgeRetries: number, + ) {} start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ - // depth, drives the one-shot, and maps the result. - return startInProcessRun(this.ctx, request, { providerName: this.name }) + // depth, drives the one-shot (including the structured capture/nudge loop + // when the request carries an outputSchema), and maps the result. + return startInProcessRun(this.ctx, request, { + providerName: this.name, + structuredNudgeRetries: this.structuredNudgeRetries, + }) } } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) + // Hold the structured runtime for the plugin's lifetime, so the capture tool + // and its request-shaping listeners are registered before the first + // structured run and torn down when the last backend unloads (live runs hold + // their own acquisitions, so an unload mid-run cannot strand a child). + ctx.effect(() => { + const acquisition = acquireStructuredRuntime(ctx) + return () => { acquisition.release() } + }, 'subagent-spawn structured runtime') + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries)) } diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index b3e9d4ec24..97dab3c3ee 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn' }) + await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) // The model-facing subagent tool, bound to the spawn backend. await ctx.plugin(ToolSubagent, { provider: 'spawn' }) return ctx diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index ccfd6492f4..70ce7a4774 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -34,7 +34,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent, adapter } @@ -241,17 +241,23 @@ 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 () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + // The backend does NOT inject 'tools' (the structured runtime gates its + // capture-tool registration on tools availability itself, keeping backend + // apply timing — and the delegation tool's prompt position — unchanged); + // the registries are loaded here so the runtime registers eagerly anyway. + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) expect(ctx.subagents.list()).toEqual(['spawn']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index ef76a96e5d..82fd12af36 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/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. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e49b4c12e0..f70abf7e72 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/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) }) }) diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index f35ed884eb..ddd725da4b 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/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' } }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5fb68c746..35d940028b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt From eaac3b58a4a6934268f45eef2f0d85edc2920589 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:55:16 +0800 Subject: [PATCH 06/36] fix review findings: close wrapped-expression, alias, and binding-pattern gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-2 review found three adjacent fail-open shapes: - Wrapped function expressions escaped classification: parentheses, as/satisfies casts, and non-null assertions are now peeled before the arrow/function test (initializers and default exports), and a single-call-signature type literal counts as the surface signature. A literal mixing call/construct signatures with anything else is refused outright — no single signature to hold the tags against. - The blanket 'export import X = N.member' skip was unsound (the target can be a non-exported namespace member no walk visits): an alias now documents itself. - The heritage extra-parameter duty missed binding-pattern extras, which no base declaration can name: they now trigger the standard binding-pattern violation. Five new negative-path tests pin the closed shapes; module doc and RFC updated. --- .../2026-07-06-export-surface-jsdoc-gate.md | 6 +- .../agent/tests/verify-export-jsdoc.spec.ts | 53 ++++++++- scripts/verify-export-jsdoc.ts | 104 +++++++++++++----- 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index f3e6ab45c5..13b9db565f 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -13,12 +13,12 @@ A new gate, `scripts/verify-export-jsdoc.ts` (`pnpm run verify-export-jsdoc`, wi The contract by declaration kind: - Every exported name needs JSDoc with non-empty description prose. -- Function-like exports (function declarations; consts with function initializers or an INLINE function-type annotation; non-identifier function default exports) follow the full function contract. A const whose declarator is annotated with a NAMED type (`export const f: Handler = …`) defers the signature contract to that type's own declaration and `@returns` stays optional; an inline `(x: T) => U` annotation is the surface signature itself and gets the full contract. +- Function-like exports (function declarations; consts with function initializers or an INLINE callable annotation; non-identifier function default exports) follow the full function contract, with wrapper expressions (parentheses, `as`/`satisfies` casts, non-null assertions) peeled before classifying. A const whose declarator is annotated with a NAMED type (`export const f: Handler = …`) defers the signature contract to that type's own declaration and `@returns` stays optional; an inline `(x: T) => U` annotation or single-call-signature literal is the surface signature itself and gets the full contract, and a literal mixing call/construct signatures with anything else is refused outright (no single signature to hold the tags against — extract a named type). - Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs. - Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate). - Exported namespaces recurse (inside an ambient `declare` namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once). -- `declare module` / `declare global` bodies, `export … from` re-export statements, and `export import X = N.member` aliases are skipped: an augmentation is not an export of the package, and a re-exported or aliased definition is checked where it is defined. -- Everything else fails CLOSED: `export =` is refused outright, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission. +- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits, so the "definition owns the doc" rationale does not hold for it. +- Everything else fails CLOSED: `export =` is refused outright, parameters the base never names keep their `@param` duty even as binding patterns, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission. Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index 6682142abf..501dfb8c3e 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -329,12 +329,45 @@ describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { ]) }) - it('skips an export-import alias (the aliased definition owns the doc)', () => { + it('requires an export-import alias to document itself (its target may be unwalked)', () => { expect(collectExportJsdocViolations(make( '/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n', + ))).toEqual([expect.stringMatching(/exported alias 'y' .* has no JSDoc\./)]) + expect(collectExportJsdocViolations(make( + 'namespace N {\n export const x = 1\n}\n/** Alias surfacing the internal counter. */\nexport import y = N.x\n', ))).toEqual([]) }) + it('classifies wrapped function initializers and default exports (parens, satisfies)', () => { + expect(collectExportJsdocViolations(make( + 'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n', + ))).toEqual([ + expect.stringMatching(/exported const 'f' .* is missing @param x\./), + expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./), + ]) + expect(collectExportJsdocViolations(make( + 'type Fn = (x: number) => number\n/** Wrapped. */\nexport default (((x: number): number => x * 2) satisfies Fn)\n', + ))).toEqual([ + expect.stringMatching(/default export .* is missing @param x\./), + expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./), + ]) + }) + + it('treats a single-call-signature type literal as the surface signature', () => { + expect(collectExportJsdocViolations(make( + '/** Maps. */\nexport declare const f: { (x: number): number }\n', + ))).toEqual([ + expect.stringMatching(/exported const 'f' .* is missing @param x\./), + expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./), + ]) + }) + + it('refuses a hybrid callable type literal instead of narrowing the check', () => { + expect(collectExportJsdocViolations(make( + '/** Hybrid. */\nexport declare const f: { (x: number): number; flush: () => void }\n', + ))).toEqual([expect.stringMatching(/exported const 'f'.*callable type literal is not gate-classifiable; extract a named type/)]) + }) + it('refuses an export-equals assignment instead of failing open', () => { expect(collectExportJsdocViolations(make( 'const x = 1\nexport = x\n', @@ -393,4 +426,22 @@ export class Impl extends Base { } `))).toEqual([]) }) + + it('flags a binding-pattern parameter an override adds beyond the base', () => { + expect(collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** + * Do it. + * @param x - input. + * @returns output. + */ + abstract run(x: number): number +} +/** Impl. */ +export class Impl extends Base { + override run(x: number, { verbose }: { verbose?: boolean } = {}): number { return verbose ? x : -x } +} +`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)]) + }) }) diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 5a8a28af1a..44b5d2fdaa 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -14,16 +14,20 @@ * - Every exported name needs JSDoc with non-empty description prose (prose * ends at the first block tag, standard JSDoc semantics). * - A function-like export (function declaration, a const with a function - * initializer or an INLINE function-type annotation, or a non-identifier + * initializer or an INLINE callable annotation, or a non-identifier * function default export) additionally needs a non-empty `@param` per * parameter (`this` receiver annotations exempt; a stale `@param` errors) * and a non-empty `@returns` unless the return type is `void` / - * `Promise`. The walk classifies returns syntactically, so the return - * type must be ANNOTATED — except a const whose declarator is annotated - * with a NAMED type (e.g. `export const f: Handler = …`), where that type's - * own declaration owns the signature contract and `@returns` stays - * optional; an inline `(x: T) => U` annotation is the surface signature - * itself and gets the full contract. + * `Promise`. Wrapper expressions (parentheses, `as` / `satisfies` + * casts, non-null assertions) are peeled before classifying. The walk + * classifies returns syntactically, so the return type must be ANNOTATED — + * except a const whose declarator is annotated with a NAMED type (e.g. + * `export const f: Handler = …`), where that type's own declaration owns + * the signature contract and `@returns` stays optional; an inline + * `(x: T) => U` annotation or single-call-signature literal is the surface + * signature itself and gets the full contract, and a literal mixing + * call/construct signatures with anything else is refused (extract a named + * type). * - An exported class needs class-level JSDoc; its public methods (static * included — they are reachable on the exported name) follow the function * contract, and public properties and accessors need description prose (on @@ -56,10 +60,12 @@ * - Overload groups: each overload signature carries its own docs; the * implementation signature is exempt (callers never see it). * - Skipped: `declare module` / `declare global` augmentation bodies (the - * cordis gate's turf; an augmentation is not an export of the package), - * re-export statements with a module specifier (`export … from`) and - * `export import X = N.member` aliases — the defining module is walked on - * its own, and external definitions are not ours to document. + * cordis gate's turf; an augmentation is not an export of the package) and + * re-export statements with a module specifier (`export … from`) — the + * defining module is walked on its own, and external definitions are not + * ours to document. An `export import X = N.member` alias documents + * ITSELF (its target may be a non-exported namespace member no walk + * visits, so a skip would fail open). * - Everything else fails CLOSED: `export =` is refused outright, and an * exported statement kind the dispatch does not recognize is itself a * violation, so no export form can pass unchecked by omission. @@ -115,6 +121,43 @@ function thisReceiver(p: ts.ParameterDeclaration): boolean { return ts.isIdentifier(p.name) && p.name.text === 'this' } +/** + * Peel wrapper expressions that carry no surface of their own — parentheses, + * `as` / `satisfies` / angle-bracket casts, non-null assertions — so a + * wrapped function expression is still classified as function-like. + * @param e - the expression to unwrap. + * @returns the innermost non-wrapper expression. + */ +function unwrapExpression(e: ts.Expression): ts.Expression { + let inner = e + while ( + ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner) + || ts.isNonNullExpression(inner) || ts.isTypeAssertionExpression(inner) + ) inner = inner.expression + return inner +} + +/** + * Classify a declarator's type annotation for the function contract: an + * inline function type or a type literal that is EXACTLY one call signature + * is the surface signature itself; a literal mixing call/construct + * signatures with anything else cannot be classified syntactically and is + * refused (fail closed — extract a named type); everything else is a plain + * value shape. + * @param type - the declarator's type annotation. + * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. + */ +function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null { + if (ts.isFunctionTypeNode(type)) return type + if (!ts.isTypeLiteralNode(type)) return null + const signatures = type.members.filter(m => ts.isCallSignatureDeclaration(m) || ts.isConstructSignatureDeclaration(m)) + if (signatures.length === 0) return null + if (signatures.length === 1 && type.members.length === 1 && signatures[0] !== undefined && ts.isCallSignatureDeclaration(signatures[0])) { + return signatures[0] + } + return 'refuse' +} + /** * The heritage-member exemption for one class member. When the member's name * is declared by an `extends`/`implements` heritage type, the seam declaration @@ -242,11 +285,12 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})` if (exemption !== null) { // The heritage declaration owns prose and @returns; parameters the - // base never names are new surface and keep their @param duty. + // base never names — including binding patterns, which no base + // declaration can name — are new surface and keep their @param duty. const base = exemption.baseParams const inBase = (p: ts.ParameterDeclaration): boolean => base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, '')) - if (base !== null && m.parameters.some(p => ts.isIdentifier(p.name) && p.name.text !== 'this' && !inBase(p))) { + if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) { const { params } = parseTags(rawJsDoc(w.text, m)) checkParams(where, 'export', m.parameters, params, w.sf, p => thisReceiver(p) || inBase(p), w.violations) @@ -316,13 +360,19 @@ function checkDecl( const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf) if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot const where = `exported const '${prefix}${name}'${at(d)}` - const init = d.initializer - if (d.type !== undefined && ts.isFunctionTypeNode(d.type)) { - // An INLINE function-type annotation is the surface signature itself: - // its parameters and result need docs right here. (A NAMED reference + const annotation = d.type !== undefined ? callableAnnotation(d.type) : null + const init = d.initializer !== undefined ? unwrapExpression(d.initializer) : undefined + if (annotation === 'refuse') { + // A literal mixing call/construct signatures with other members (or + // overloading them) has no single signature the walk can hold the + // tags against — fail closed rather than silently narrow the check. + w.violations.push(`${where}: its callable type literal is not gate-classifiable; extract a named type and document it there.`) + } else if (annotation !== null) { + // An INLINE callable annotation is the surface signature itself: its + // parameters and result need docs right here. (A NAMED reference // type carries its docs at the type's own declaration instead.) - checkFunctionLike(where, raw, d.type.parameters, d.type.type, false, w) - } else if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { + checkFunctionLike(where, raw, annotation.parameters, annotation.type, false, w) + } else if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { // A named declarator type annotation (`const f: Handler = …`) hands // the return contract to the named type; the arrow's own annotation is // still checked when it is the only signature the reader has. @@ -354,7 +404,11 @@ function checkDecl( return } if (ts.isImportEqualsDeclaration(stmt)) { - return // alias re-export (`export import X = N.member`): the aliased definition owns the doc, like `export … from` + // An alias (`export import X = N.member`) is a distinct exported name and + // its target may be a non-exported namespace member no walk ever visits, + // so a blanket skip would fail open — the alias documents itself. + checkDescribed(`exported alias '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + return } // Fail CLOSED: an exported statement kind this dispatch does not recognize // must never pass silently — the gate's whole promise is that unchecked @@ -422,11 +476,11 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk continue } const where = `default export (${pointer(w.rel, w.sf, stmt)})` - if (ts.isIdentifier(stmt.expression)) { - for (const decl of byName.get(stmt.expression.text) ?? []) check(decl) - } else if (ts.isArrowFunction(stmt.expression) || ts.isFunctionExpression(stmt.expression)) { - const fn = stmt.expression - checkFunctionLike(where, rawJsDoc(w.text, stmt), fn.parameters, fn.type, false, w) + const expr = unwrapExpression(stmt.expression) + if (ts.isIdentifier(expr)) { + for (const decl of byName.get(expr.text) ?? []) check(decl) + } else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) { + checkFunctionLike(where, rawJsDoc(w.text, stmt), expr.parameters, expr.type, false, w) } else { checkDescribed(where, rawJsDoc(w.text, stmt), w) } From a520965f09a9721ccfec1dbaf2515fd4674ac872 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:11:55 +0800 Subject: [PATCH 07/36] fix review findings: post-capture tool calls denied; schema snapshotted at start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bot findings on the structured runtime: - Terminal means terminal WITHIN the step: a model response listing structured_output before further tool calls executed those calls after the final answer was accepted (the turn-continuation veto only fires at step end). A third runtime listener now denies every call for a captured agent at the tools/pre-execute gate — dispatch skipped, isError result naming the contract. Calls preceding the capture in the same response are untouched. - The output schema is structuredClone'd before the subset assertion: the caller keeps its reference, so asserting and attaching the original let a post-start() mutation drift the enforced schema away from the asserted one. The clone pins assertion, model-visible parameters, and validation to one value. --- .../subagent/subagent-inprocess/src/index.ts | 11 ++- .../subagent-inprocess/src/structured.ts | 31 ++++++- .../tests/structured.spec.ts | 86 ++++++++++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 381f94acba..1309556f3f 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -119,9 +119,14 @@ export function startInProcessRun( if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } - // Assert the schema subset BEFORE any child exists (the service has already - // capability-gated; this rejects a schema outside the enforced subset loud). - const schema = request.outputSchema + // Snapshot, then assert, the schema subset BEFORE any child exists (the + // service has already capability-gated; this rejects a schema outside the + // enforced subset loud). The snapshot is load-bearing: the caller keeps its + // reference, so validating and attaching the ORIGINAL would let a + // post-start() mutation drift the enforced schema away from the asserted + // one — the clone pins assertion, the model-visible parameters, and + // validateStructuredValue to the same isolation-immutable value. + const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) if (schema !== undefined) assertSupportedOutputSchema(schema) const childId = AgentId(randomUUID()) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 69e4ea4fd6..e866e5e699 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -25,7 +25,11 @@ * 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. + * 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. * * Lifetime is refcounted with two kinds of holder: each backend acquires for * its plugin lifetime (so the tool exists before any run), and each structured @@ -42,7 +46,7 @@ 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 { ToolExecution } from '@deepseek-ai/dsh-tools' +import type { PreToolDecision, ToolExecution } 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. */ @@ -236,4 +240,27 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void { if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) return next() }, { 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, + ): Promise { + 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 })) } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 6982f7cde5..006fcd9d8a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { type GenerateOptions } from '@deepseek-ai/dsh-llm' +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' @@ -86,6 +86,90 @@ describe('in-process structured output', () => { 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 { + 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 { + 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).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) From f7bd7e82d177cf1eee7a8cb999875075fd86795b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:20:55 +0800 Subject: [PATCH 08/36] fix review findings: restrict export-import aliases to prose-only targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-3: alias prose matches the gate's strength only when the target's own contract is prose-only. An export-import alias to a function, class, or namespace target (or an unresolvable one) is now refused — those carry signature/member contracts the alias cannot hold; export the declaration directly instead. Const/enum/interface/ type-alias targets keep the self-documentation contract. Tests pin the refusal for function, class, and namespace targets; module doc and RFC updated. --- .../2026-07-06-export-surface-jsdoc-gate.md | 2 +- .../agent/tests/verify-export-jsdoc.spec.ts | 13 +++++++++ scripts/verify-export-jsdoc.ts | 28 +++++++++++++++---- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 13b9db565f..54c5c54e44 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -17,7 +17,7 @@ The contract by declaration kind: - Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs. - Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate). - Exported namespaces recurse (inside an ambient `declare` namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once). -- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits, so the "definition owns the doc" rationale does not hold for it. +- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits — and only prose-only target kinds are gate-supported: a callable, class, or namespace target carries signature/member contracts the alias prose cannot hold, so the gate refuses it and demands the declaration be exported directly. - Everything else fails CLOSED: `export =` is refused outright, parameters the base never names keep their `@param` duty even as binding patterns, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission. Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index 501dfb8c3e..b95fbdcd9b 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -338,6 +338,19 @@ describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { ))).toEqual([]) }) + it('refuses an export-import alias to a callable, class, or namespace target', () => { + const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/ + expect(collectExportJsdocViolations(make( + 'namespace N {\n export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n', + ))).toEqual([expect.stringMatching(refusal)]) + expect(collectExportJsdocViolations(make( + 'namespace N {\n export class C {\n run(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.C\n', + ))).toEqual([expect.stringMatching(refusal)]) + expect(collectExportJsdocViolations(make( + 'namespace N {\n export namespace Sub {\n export function f(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.Sub\n', + ))).toEqual([expect.stringMatching(refusal)]) + }) + it('classifies wrapped function initializers and default exports (parens, satisfies)', () => { expect(collectExportJsdocViolations(make( 'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n', diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 44b5d2fdaa..a2d8a82f97 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -64,8 +64,9 @@ * re-export statements with a module specifier (`export … from`) — the * defining module is walked on its own, and external definitions are not * ours to document. An `export import X = N.member` alias documents - * ITSELF (its target may be a non-exported namespace member no walk - * visits, so a skip would fail open). + * ITSELF, and only prose-only target kinds are gate-supported: a callable, + * class, or namespace target carries signature/member contracts the alias + * cannot hold and is refused (export the declaration directly). * - Everything else fails CLOSED: `export =` is refused outright, and an * exported statement kind the dispatch does not recognize is itself a * violation, so no export form can pass unchecked by omission. @@ -404,10 +405,25 @@ function checkDecl( return } if (ts.isImportEqualsDeclaration(stmt)) { - // An alias (`export import X = N.member`) is a distinct exported name and - // its target may be a non-exported namespace member no walk ever visits, - // so a blanket skip would fail open — the alias documents itself. - checkDescribed(`exported alias '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w) + const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}` + // An alias is a distinct exported name whose target may be a non-exported + // namespace member no walk ever visits, so it documents ITSELF — which + // matches the gate's strength only for prose-only target kinds. A + // callable, class, or namespace target carries signature or member + // contracts the alias prose cannot hold: refuse those (fail closed) and + // demand the declaration be exported directly. An unresolvable target is + // refused for the same reason. + const sym = w.checker.getSymbolAtLocation(stmt.name) + const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym + const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule + const rich = target === undefined + || (target.flags & RICH_TARGETS) !== 0 + || w.checker.getTypeOfSymbol(target).getCallSignatures().length > 0 + if (rich) { + w.violations.push(`${where} aliases a callable, class, or namespace target whose signature/member contract the alias cannot carry; export the declaration directly instead.`) + return + } + checkDescribed(where, rawJsDoc(w.text, stmt), w) return } // Fail CLOSED: an exported statement kind this dispatch does not recognize From b907c20213e67805c1a2da097986683932c37114 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:14:48 +0800 Subject: [PATCH 09/36] review: drop the structured-output nudge; FIXME the context-global registry constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two human review directives: - No re-prompt. A structured child that finishes a turn cleanly without calling structured_output settles error to the parent immediately — readResult already carried that mapping; the nudge loop only delayed it. Deletes the loop, its cancellation-window guard, STRUCTURED_OUTPUT_NUDGE, and the structuredNudgeRetries Config on both backends. - FIXME in the structured module doc: per-agent/per-session tool registry and prompt assembly would dissolve the final-assembly enforcement dance (the placeholder tool, the swap, the strip, the global-registration lifetime). --- packages/subagent/subagent-fork/README.md | 1 - packages/subagent/subagent-fork/src/index.ts | 17 +----- .../tests/multi-subagent.spec.ts | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 42 ++----------- .../subagent-inprocess/src/structured.ts | 12 ++-- .../tests/structured.spec.ts | 61 ++++++------------- .../tests/subagent-inprocess.spec.ts | 6 +- packages/subagent/subagent-spawn/README.md | 3 +- packages/subagent/subagent-spawn/src/index.ts | 25 ++------ .../subagent/subagent-spawn/tests/harness.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- 13 files changed, 50 insertions(+), 135 deletions(-) diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 7b43f82261..1abd7951fd 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -19,6 +19,5 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `fork`). | -| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 10d0492198..4ee28001d2 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -34,20 +34,14 @@ export const name = 'subagent-fork' // model-visible tool list) is unchanged by structured output. export const inject = ['subagents', 'agents'] -/** Config: the registry name to register the provider under, plus structured-run tuning. */ +/** Config: the registry name to register the provider under. */ export interface Config { /** Provider name on `ctx.subagents` (default `fork`). */ providerName: string - /** - * How many times a structured run re-prompts a child that finished cleanly - * without calling `structured_output` before giving up (default 1). - */ - structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('fork'), - structuredNudgeRetries: z.natural().default(1), }) /** @@ -76,17 +70,12 @@ class ForkProvider implements SubagentProvider { // Context contract: a forked child IS seeded with the parent's completed-turn prefix. readonly inheritsParentContext = true - constructor( - readonly name: string, - private readonly ctx: Context, - private readonly structuredNudgeRetries: number, - ) {} + constructor(readonly name: string, private readonly ctx: Context) {} start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(this.ctx, request, { providerName: this.name, - structuredNudgeRetries: this.structuredNudgeRetries, // Only pass a seed when there's a completed turn to inherit; an empty seed // is equivalent to a fresh child, so omit it to keep the session unseeded. ...seed.length > 0 ? { seed } : {}, @@ -102,5 +91,5 @@ export function apply(ctx: Context, config: Config): void { const acquisition = acquireStructuredRuntime(ctx) return () => { acquisition.release() } }, 'subagent-fork structured runtime') - ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries)) + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 82caf25948..1f932fbaf9 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -30,8 +30,8 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) - await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 2545188b52..74974942b5 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -37,7 +37,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) + await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } @@ -176,7 +176,7 @@ describe('dsh-subagent-fork', () => { // the registries are loaded here so the runtime registers eagerly anyway. await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) + const fiber = await ctx.plugin(fork, { providerName: 'fork' }) expect(ctx.subagents.list()).toEqual(['fork']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index b816da8946..a4fcc51fbc 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -10,14 +10,14 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( 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)) before any child exists; 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); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times; +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`. ### `InProcessRunOptions` -`{ providerName: string; seed?: SessionEvent[]; structuredNudgeRetries: number }` — the per-backend inputs: the provider name (for error context), the optional child-session seed, and the structured-run nudge budget (REQUIRED, resolved from the backend's validated Config — the driver never fills it with a hidden default). +`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. ### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition` diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1309556f3f..72906a78fc 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -22,7 +22,6 @@ import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { acquireStructuredRuntime, - STRUCTURED_OUTPUT_NUDGE, type StructuredAcquisition, } from './structured.ts' @@ -30,7 +29,6 @@ export { acquireStructuredRuntime, STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_INSTRUCTION, - STRUCTURED_OUTPUT_NUDGE, type StructuredAcquisition, } from './structured.ts' @@ -90,13 +88,6 @@ export interface InProcessRunOptions { * parent's log (FORK), or `undefined` for a fresh child (SPAWN). */ readonly seed?: SessionEvent[] - /** - * How many times a structured run re-prompts a child that finished a turn - * cleanly WITHOUT calling `structured_output` (see the structured module). - * REQUIRED, resolved from the backend's validated Config — per the explicit- - * defaulting rule, the driver never fills it with a hidden fallback. - */ - readonly structuredNudgeRetries: number } /** @@ -178,7 +169,7 @@ export function startInProcessRun( 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 `!cancelled` in the nudge condition reads as always-true. + // inline read at the result mapping would narrow to the initializer. const isCancelled = (): boolean => cancelled const requestCancel = (reason: string): void => { cancelled = true @@ -196,28 +187,9 @@ export function startInProcessRun( if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } child.send(request.prompt) await child.whenIdle() - if (structured) { - // Nudge loop: a child that finished a turn CLEANLY without calling - // structured_output gets re-prompted, up to the backend-configured - // retry count. An errored/aborted turn is not nudged — its failure is - // the honest result (a cancelled turn ends `aborted`, and a pre-turn - // cancel leaves no `turn/end` at all, so neither reads `completed`). - // `!cancelled` closes the remaining window: a cancel landing AFTER a - // clean turn end clears nothing — `child.cancel()` only kills - // queued/running work — so without it the next `send` would spend a - // fresh post-cancellation turn; the condition re-evaluates after - // every `whenIdle()`, so a mid-nudge cancel stops the loop at the - // next boundary too. - let nudges = options.structuredNudgeRetries - while ( - !isCancelled() && structured.captured(child) === undefined && nudges > 0 - && lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed' - ) { - nudges -= 1 - child.send([{ type: 'text', text: STRUCTURED_OUTPUT_NUDGE }]) - await child.whenIdle() - } - } + // 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) @@ -241,12 +213,6 @@ export function startInProcessRun( } } -/** The child's OWN last `turn/end` event (events at or after `seedLength`), if any. */ -function lastOwnTurnEnd(child: Agent, seedLength: number): SessionEvent<'turn/end'> | undefined { - return child.session.events.slice(seedLength) - .findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') -} - /** * Read a settled child's terminal result from its session log, scoped to the * child's OWN events (everything at or after `seedLength` — fork seeds the diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index e866e5e699..370f7a538d 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -21,6 +21,13 @@ * 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 @@ -65,11 +72,6 @@ export const STRUCTURED_OUTPUT_INSTRUCTION + `\`${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.' -/** The nudge sent when a structured child finishes cleanly without calling the tool. */ -export const STRUCTURED_OUTPUT_NUDGE - = `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. ` - + `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.` - /** One structured run's state: the schema to enforce and the captured value, once recorded. */ interface RunState { readonly schema: StructuredOutputSchema diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 006fcd9d8a..ba693cfecd 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -32,7 +32,7 @@ const SCHEMA: StructuredOutputSchema = { * structured runtime at apply, exactly as shipped). The mock model script * drives the child's structured_output calls. */ -async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) { +async function setup(script: Script, options?: { withFork?: boolean }) { const ctx = new Context() const adapter = new MockAdapter(script) await ctx.plugin(LlmService) @@ -43,9 +43,9 @@ async function setup(script: Script, options?: { nudges?: number; withFork?: boo await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 }) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) const forkFiber = options?.withFork - ? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 }) + ? await ctx.plugin(fork, { providerName: 'fork' }) : undefined ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) @@ -215,47 +215,25 @@ describe('in-process structured output', () => { await run.dispose() }) - it('nudges a child that finished cleanly without calling the tool, then captures', async () => { - const { ctx, parent } = await setup([ - textResponse('here is my answer in prose'), - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), - ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - expect(result.structured).toEqual({ answer: 3 }) - expect(result.stopReason).toBe('completed') - // The nudge is a real user-visible message in the child's log. - const child = ctx.agents.get(run.id)! - const users = child.session.events.filter(e => e.type === 'user/message') - expect(users.length).toBe(2) - await run.dispose() - }) - - it('settles error when the nudges run out without a capture', async () => { + 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('prose only'), - textResponse('still prose'), - ], { nudges: 1 }) + 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() - expect(adapter.requests.length).toBe(2) - await run.dispose() - }) - - it('zero nudge retries fails immediately after the first clean prose finish', async () => { - const { ctx, parent, adapter } = await setup([textResponse('prose')], { nudges: 0 }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - expect(result.stopReason).toBe('error') + // 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('a child that errored is NOT nudged (its failure is the honest result)', async () => { + 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([], { nudges: 3 }) + const { ctx, parent, adapter } = await setup([]) const run = ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('error') @@ -263,22 +241,17 @@ describe('in-process structured output', () => { await run.dispose() }) - it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => { - const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 }) + 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 first turn's end recording — after the - // turn reads `completed`, before the nudge continuation resumes. The turn - // state alone cannot see this cancel (`child.cancel()` only clears - // queued/running work), so without the loop's own cancelled check the - // next send would spend a fresh child turn after the caller cancelled. + // 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 between turn end and nudge') + if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end') }) const result = await run.result expect(result.stopReason).toBe('aborted') - // Exactly one model request: the nudge turn never ran. - expect(adapter.requests.length).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d3870ae51a..7219e03988 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -51,7 +51,7 @@ describe('depthOf', () => { describe('startInProcessRun', () => { it('drives a fresh child (no seed) to completion and returns its output', async () => { const { ctx, parent } = await setup([textResponse('driver child answer')]) - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver child answer') @@ -61,7 +61,7 @@ describe('startInProcessRun', () => { it('throws SubagentDepthError when the child would exceed maxDepth', async () => { const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 })) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) .toThrow(SubagentDepthError) }) @@ -73,7 +73,7 @@ describe('startInProcessRun', () => { parent.send([{ type: 'text', text: 'parent q' }]) await parent.whenIdle() const seed = parent.session.events.slice() - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('seeded child reply') diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 059c996215..696007a693 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, { providerName, structuredNudgeRetries })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities @@ -17,4 +17,3 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | -| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 248e887ce7..7da954bc44 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -32,20 +32,14 @@ export const name = 'subagent-spawn' // structured output existed. export const inject = ['subagents', 'agents'] -/** Config: the registry name to register the provider under, plus structured-run tuning. */ +/** Config: the registry name to register the provider under. */ export interface Config { /** Provider name on `ctx.subagents` (default `spawn`). */ providerName: string - /** - * How many times a structured run re-prompts a child that finished cleanly - * without calling `structured_output` before giving up (default 1). - */ - structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('spawn'), - structuredNudgeRetries: z.natural().default(1), }) /** @@ -59,20 +53,13 @@ class SpawnProvider implements SubagentProvider { // Context contract: a spawned child starts fresh — it never sees the parent conversation. readonly inheritsParentContext = false - constructor( - readonly name: string, - private readonly ctx: Context, - private readonly structuredNudgeRetries: number, - ) {} + constructor(readonly name: string, private readonly ctx: Context) {} start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ - // depth, drives the one-shot (including the structured capture/nudge loop - // when the request carries an outputSchema), and maps the result. - return startInProcessRun(this.ctx, request, { - providerName: this.name, - structuredNudgeRetries: this.structuredNudgeRetries, - }) + // 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 }) } } @@ -85,5 +72,5 @@ export function apply(ctx: Context, config: Config): void { const acquisition = acquireStructuredRuntime(ctx) return () => { acquisition.release() } }, 'subagent-spawn structured runtime') - ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries)) + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) } diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 97dab3c3ee..b3e9d4ec24 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(Spawn, { providerName: 'spawn' }) // The model-facing subagent tool, bound to the spawn backend. await ctx.plugin(ToolSubagent, { provider: 'spawn' }) return ctx diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 70ce7a4774..63d26c0531 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -34,7 +34,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent, adapter } @@ -257,7 +257,7 @@ describe('dsh-subagent-spawn', () => { // the registries are loaded here so the runtime registers eagerly anyway. await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) expect(ctx.subagents.list()).toEqual(['spawn']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) From 5a91893b860b2b80f8bf9a7d5a71da85b5f53f89 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:37:50 +0800 Subject: [PATCH 10/36] test: drain the detached SubagentStart continuation before the bridge spec ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markers are touched mid-script, so runPoint's continuation chain (child exit → merge → the listener's detached .then) can still be in flight when the marker poll resolves; a vitest worker that exits first leaves the inject condition's short-circuit path uncounted. Observed as a CI-only 99.03% branch-coverage flake on hooks-claude — surfaced by this branch shifting suite timing, latent before it. Two macrotask rounds pin the path deterministically. --- packages/hooks/hooks-claude/tests/bridge.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 3e36231e66..652edf66b7 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -298,6 +298,14 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) expect(existsSync(startMarker)).toBe(true) expect(existsSync(stopMarker)).toBe(true) + // The markers are touched MID-script, so runPoint's continuation chain + // (child-exit event → merge → the listener's detached .then) can still be + // in flight when the poll resolves. Drain two macrotask rounds so the + // short-circuit path of the SubagentStart inject condition executes before + // this worker can exit — observed as a CI-only 99.03% branch-coverage + // flake on hooks-claude when the worker won the race. + await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise(resolve => setTimeout(resolve, 0)) }) }) From 8c8189844f6bf35c697132ff593b2aeb176b9439 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:44:18 +0800 Subject: [PATCH 11/36] docs: regenerate the config catalog after the master merge Master's generated config catalog (#188, flattened paths #191) now records plugin Configs; the nudge removal dropped structuredNudgeRetries from both backends, so the regenerated catalog loses those rows. --- docs/config-catalog.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 66766144a8..8a10012050 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -218,7 +218,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -243,7 +243,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-invariants` @@ -329,7 +329,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:415`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -481,7 +481,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` @@ -528,7 +528,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` From 42e7a2f6915a18f4e0bde83cd054768851d9695b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:03:11 +0800 Subject: [PATCH 12/36] fix: add tools reorder to system prompt --- docs/config-catalog.md | 53 ++++++--- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 2 +- docs/rfc/INDEX.md | 1 + .../feature/2026-07-06-explicit-tool-order.md | 40 +++++++ packages/core/agent-core/README.md | 4 +- packages/core/agent-core/src/index.ts | 32 +++--- .../core/agent-core/tests/agent-core.spec.ts | 17 +++ .../core/agent-loop/tests/tool-order.spec.ts | 94 ++++++++++++++++ packages/core/system-prompt/README.md | 1 + packages/core/system-prompt/src/index.ts | 102 ++++++++++++++++-- .../system-prompt/tests/tool-order.spec.ts | 90 ++++++++++++++++ packages/ui/acp-agent/README.md | 1 + packages/ui/acp-agent/src/index.ts | 10 +- packages/ui/acp-agent/tests/acp-agent.spec.ts | 21 ++++ packages/ui/stdio-agent/README.md | 1 + packages/ui/stdio-agent/src/index.ts | 10 +- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 21 ++++ 18 files changed, 459 insertions(+), 43 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md create mode 100644 packages/core/agent-loop/tests/tool-order.spec.ts create mode 100644 packages/core/system-prompt/tests/tool-order.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 66766144a8..0254ea7267 100644 --- a/docs/config-catalog.md +++ b/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` @@ -218,7 +224,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -243,7 +249,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-invariants` @@ -329,7 +335,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:415`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -390,7 +396,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 { @@ -398,6 +405,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.'`. */ @@ -411,7 +420,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` @@ -547,10 +556,26 @@ 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, names with no registered tool are + * ignored, and tools absent from the list are inserted at the + * {@link TOOL_ORDER_REST} (`'...'`) entry in lexicographic name order. A + * configured list must contain `'...'` exactly once and no duplicate names — + * anything else throws at load; a bad order config must never reach a + * model request. 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:113`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:161`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0ae4ea77e..db7b0eb032 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -189,7 +189,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:262`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8cd9f1c30b..7d1110d7a8 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -187,7 +187,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-content half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt and assembled tool schemas — 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 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-content half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt and the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset) — 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 loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index bcd78d8b88..e6fe26a730 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -59,6 +59,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.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 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md new file mode 100644 index 0000000000..8f8bc0d920 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -0,0 +1,40 @@ +# 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: + +- **One config key on `dsh-system-prompt`.** `toolOrder?: string[]` names tools in the exact order to send. A listed tool takes its listed position; a listed name with no registered tool is ignored (a deployment may list optional tools it does not always load); tools absent from the list are inserted at the `'...'` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among themselves. The list must contain `'...'` exactly once and no duplicate names — violations throw from the service constructor, failing the fiber at load, never mid-conversation. When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent) — determinism requires no configuration. +- **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 service surface and no 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 `'...'`), 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. + +## 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. +- Snapshot fixtures and goldens were re-recorded (the `request/header.tools` segments changed); the authored, never-re-recorded scenarios (`cancel`, `error-finish`) had their fixture headers reordered by hand. +- 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. + +## Testing + +Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/ignored/rest placement, 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, and that the frozen loop-built envelope survives to the adapter. 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 against re-recorded goldens whose headers carry the canonical order. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 353e68332d..3f2ff08c0e 100644 --- a/packages/core/agent-core/README.md +++ b/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 diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 0831ae929e..785c8d1ff2 100644 --- a/packages/core/agent-core/src/index.ts +++ b/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) diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 4a4c5587ed..9d7534ab4e 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -67,6 +67,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', '...'] }) + // 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') diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts new file mode 100644 index 0000000000..97cf78e48f --- /dev/null +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -0,0 +1,94 @@ +/** + * 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 { + 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) + }) +}) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index be705de03e..0e4467322f 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -7,6 +7,7 @@ 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 `'...'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, names with no registered tool are ignored, unlisted tools land at `'...'` 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. A list without exactly one `'...'`, or with duplicates, throws at load. 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`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c970c66b30..6eb14f589c 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/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,53 @@ 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). + * Deliberately not a valid model-facing tool name, so it can never collide + * with a real tool. + */ +export const TOOL_ORDER_REST = '...' + +/** + * Validate a configured tool-order list at service construction: `'...'` + * ({@link TOOL_ORDER_REST}) 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. + */ +function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined { + if (toolOrder === undefined) return undefined + const seen = new Set() + 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 `'...'` entry in + * lexicographic name order. 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[] { + if (toolOrder === undefined) return tools.sort(compareToolNames) + 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 +} + export interface Config { /** * The deployment's persona — the ONE deployment-authored fragment of the @@ -124,6 +172,22 @@ 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, names with no registered tool are + * ignored, and tools absent from the list are inserted at the + * {@link TOOL_ORDER_REST} (`'...'`) entry in lexicographic name order. A + * configured list must contain `'...'` exactly once and no duplicate names — + * anything else throws at load; a bad order config must never reach a + * model request. 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[] } /** @@ -198,14 +262,23 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ persona: z.string().default(''), + // A schemastery array defaults to [] when omitted, but an omitted + // toolOrder must stay absent ("lexicographic order"), not become an + // explicitly-configured empty list (which is invalid — it lacks the '...' + // 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 | 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 @@ -318,14 +391,19 @@ 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), 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. @@ -343,8 +421,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)) diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts new file mode 100644 index 0000000000..0293f03e04 --- /dev/null +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -0,0 +1,90 @@ +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 { + 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', () => { + it('exports the rest entry as "..."', () => { + expect(TOOL_ORDER_REST).toBe('...') + }) + + 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 "..." lexicographically, absent names ignored', async () => { + const ctx = await mount({ toolOrder: ['todo_write', 'ghost', 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('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 "..." 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') + }) +}) diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 4a114619a6..8e2026a00a 100644 --- a/packages/ui/acp-agent/README.md +++ b/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 `'...'` rest entry; absent — lexicographic), 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`). diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 3bd498dc63..898ec9a509 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/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 = 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 = 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 }) diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 87cf670107..4438364bae 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -52,6 +52,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', '...'], + 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 diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index fd608b7888..f5f4dc66b5 100644 --- a/packages/ui/stdio-agent/README.md +++ b/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 `'...'` rest entry; absent — lexicographic), 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) | diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index fe63e02643..7d36db373e 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/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 = 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, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 09fbf8987d..c06ddeee02 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -74,6 +74,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', '...'], + 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 From 3c572cf70a09a00caf05262c964085cca4bfac87 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:43 +0800 Subject: [PATCH 13/36] fix CI: stop fixture programs parsing the default lib (spec timeouts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage CI lane timed out (5000ms/test) in verify-export-jsdoc.spec.ts: every test builds a ts.Program, and the fixture branch of loadCompilerOptions omitted noLib, so each of the 34 tests parsed the full default lib — measured 231ms/program bare, ~1.5s/test under coverage instrumentation locally, past 5s on a 2-core runner. Fixtures are self-contained and nothing in the walk resolves a lib symbol: noLib + types:[] drops program creation to ~1ms and the spec's coverage-mode test time from 53s to 0.16s. The real-repo branch (tsconfig.base.json options) is untouched. --- scripts/verify-export-jsdoc.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index a2d8a82f97..3845d60f1b 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -510,15 +510,17 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk * Compiler options for the walk's program. The real repo hands over its * tsconfig.base.json (whose `paths` map resolves cross-package imports to * source, so heritage-member lookups see seam types); a fixture root without - * one gets bare defaults — fixtures are single-file and self-contained. - * Emit-side options are stripped: the walk never emits or asks for - * diagnostics, it only binds types on demand. + * one gets `noLib` + no `@types` — fixtures are single-file and + * self-contained, nothing in the walk resolves a lib symbol, and default-lib + * parsing is ~99% of per-program cost (it made the fixture spec time out + * under CI coverage instrumentation). Emit-side options are stripped: the + * walk never emits or asks for diagnostics, it only binds types on demand. * @param scanRoot - the root being scanned. * @returns compiler options for ts.createProgram. */ function loadCompilerOptions(scanRoot: string): ts.CompilerOptions { const cfgPath = resolve(scanRoot, 'tsconfig.base.json') - if (!existsSync(cfgPath)) return { skipLibCheck: true } + if (!existsSync(cfgPath)) return { skipLibCheck: true, noLib: true, types: [] } const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown } const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot) return { From b67cda482a86fa56462cefda5f73f33400e31c63 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:45:27 +0800 Subject: [PATCH 14/36] fix: update snaphsot --- .../rfc/implemented/feature/2026-07-06-explicit-tool-order.md | 4 ++-- examples/acp-agent/tests/snapshots/text-turn/session.jsonl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 8f8bc0d920..89226788dc 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -31,10 +31,10 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - 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. -- Snapshot fixtures and goldens were re-recorded (the `request/header.tools` segments changed); the authored, never-re-recorded scenarios (`cancel`, `error-finish`) had their fixture headers reordered by hand. +- 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. ## Testing -Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/ignored/rest placement, 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, and that the frozen loop-built envelope survives to the adapter. 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 against re-recorded goldens whose headers carry the canonical order. +Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/ignored/rest placement, 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, and that the frozen loop-built envelope survives to the adapter. 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}}`. diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index afdd312983..2a71c46b18 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783279329596,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783279329596,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783279329598,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783279329598,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-q0sbE9.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783279329598,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-q0sbE9.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783279330062,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783279330062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783279330154,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} From 51b715433d8f89b96901a008d45908aabec09f3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:05:32 +0800 Subject: [PATCH 15/36] fix review finding: an export list surfaces only the declarators it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name resolved through an export list (or a default-export identifier) mapped back to its whole VariableStatement, and checkDecl walked every declarator — so a private sibling sharing the statement with an exported const was wrongly required to carry JSDoc. The scope dispatch is now two-phase: requests accumulate per statement (null = whole statement for a direct export modifier or ambient scope; name sets union across lists, so two lists naming different declarators of one statement both count), then each surfaced statement is checked once with the declarator filter. Regressions pin the private-sibling skip, the cross-list union, and the default-export sibling. --- .../agent/tests/verify-export-jsdoc.spec.ts | 30 +++++++++++++ scripts/verify-export-jsdoc.ts | 42 +++++++++++++++---- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index b95fbdcd9b..ae7dd1bf81 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -160,6 +160,36 @@ describe('verify-export-jsdoc export forms', () => { ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)]) }) + it('does not treat a never-exported sibling declarator as surface (review round 2)', () => { + // `export { publicValue }` resolves to the whole variable statement; only + // the named declarator is surface — the gate must not demand JSDoc for + // the private sibling sharing the statement. + expect(collectExportJsdocViolations(make( + '/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n', + ))).toEqual([]) + }) + + it('unions declarators across multiple export lists over one statement (review round 2)', () => { + // Two lists each name one declarator of the same undocumented statement: + // both are surface (deduplicating on first resolution would drop `b`), + // while the never-exported `c` stays out. + const violations = collectExportJsdocViolations(make( + 'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n', + )) + expect(violations).toEqual([ + expect.stringMatching(/exported const 'a' .* has no JSDoc\./), + expect.stringMatching(/exported const 'b' .* has no JSDoc\./), + ]) + }) + + it('scopes a default-export identifier to its own declarator (review round 2)', () => { + // `export default` of an identifier reaches the statement through the + // same name lookup as an export list; the sibling stays private. + expect(collectExportJsdocViolations(make( + '/** The app entry. */\nconst app = 1, scratch = 2\nexport default app\nvoid scratch\n', + ))).toEqual([]) + }) + it('reports a re-exported module once, at its defining file', () => { const violations = collectExportJsdocViolations(fixture({ 'index.ts': "export * from './other.ts'\n", diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 3845d60f1b..46e2f82f2b 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -321,6 +321,11 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { * @param byName - this scope's named declarations (for namespace/sibling-merge lookups). * @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly. * @param w - the walk state violations append to. + * @param only - for a multi-declarator variable statement reached through an + * export list (or a default-export identifier), the declarator names that + * are actually exported; `null` means the whole statement is surface + * (direct `export` modifier or ambient scope). Non-variable statements + * declare exactly one name, so the filter never applies to them. */ function checkDecl( stmt: ts.Statement, @@ -329,6 +334,7 @@ function checkDecl( byName: Map, ambient: boolean, w: Walk, + only: ReadonlySet | null = null, ): void { const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})` if (ts.isFunctionDeclaration(stmt)) { @@ -359,6 +365,7 @@ function checkDecl( const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator for (const d of stmt.declarationList.declarations) { const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf) + if (only !== null && !only.has(name)) continue // sibling declarator the export list never named: not surface if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot const where = `exported const '${prefix}${name}'${at(d)}` const annotation = d.type !== undefined ? callableAnnotation(d.type) : null @@ -462,11 +469,25 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk } } } - const checked = new Set() - const check = (stmt: ts.Statement): void => { - if (checked.has(stmt)) return - checked.add(stmt) - checkDecl(stmt, prefix, overloadSigs, byName, ambient, w) + // Two-phase dispatch. Phase one accumulates WHICH statements are surface + // and, for a variable statement reached by name (an export list or a + // default-export identifier), which of its declarators the exports actually + // name — `null` marks the whole statement as surface (a direct `export` + // modifier, or an ambient scope). Requests for the same statement merge: + // `null` absorbs any name set, and name sets union, so + // `export { a }; export { b }` over one `const a = …, b = …` checks both + // declarators while a never-exported sibling stays out of the surface. + // Phase two runs each surfaced statement exactly once. (Checking a + // statement eagerly per request would either re-check on the second list or + // — deduplicated — silently drop the second list's declarators.) + const requested = new Map | null>() + const request = (stmt: ts.Statement, name: string | null): void => { + const prior = requested.get(stmt) + if (name === null || prior === null) { + requested.set(stmt, null) + return + } + requested.set(stmt, prior === undefined ? new Set([name]) : prior.add(name)) } for (const stmt of statements) { if (ts.isModuleDeclaration(stmt) @@ -477,7 +498,8 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { for (const el of stmt.exportClause.elements) { - for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl) + const local = (el.propertyName ?? el.name).text + for (const decl of byName.get(local) ?? []) request(decl, local) // a name with no local declaration is an imported binding re-exported // without a specifier — its defining module is walked on its own } @@ -494,7 +516,7 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk const where = `default export (${pointer(w.rel, w.sf, stmt)})` const expr = unwrapExpression(stmt.expression) if (ts.isIdentifier(expr)) { - for (const decl of byName.get(expr.text) ?? []) check(decl) + for (const decl of byName.get(expr.text) ?? []) request(decl, expr.text) } else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) { checkFunctionLike(where, rawJsDoc(w.text, stmt), expr.parameters, expr.type, false, w) } else { @@ -502,7 +524,11 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk } continue } - if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) check(stmt) + if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null) + } + for (const stmt of statements) { + const only = requested.get(stmt) + if (only !== undefined) checkDecl(stmt, prefix, overloadSigs, byName, ambient, w, only) } } From f33e14ff19721f372cef9ceefdfc858ebc757240 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:46:10 +0800 Subject: [PATCH 16/36] build: lower the Node engines floor to 22.18 --- .github/workflows/ci.yml | 4 +- .github/workflows/e2e.yml | 13 +++++- AGENTS.md | 2 +- docs/core-data-structures/web.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- docs/rfc/INDEX.md | 1 + .../2026-06-24-web-capability-seam.md | 2 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-07-06-node-22-18-floor.md | 30 +++++++++++++ .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- package.json | 4 +- .../session-persistence-sqlite/README.md | 2 +- .../ui/stdio-agent/tests/built-bin.e2e.ts | 7 ++-- packages/web/web-fetch-local/src/provider.ts | 2 +- .../web/web-search-deepseek/src/provider.ts | 2 +- packages/web/web-search-exa/src/provider.ts | 2 +- .../web/web-search-perplexity/src/provider.ts | 2 +- pnpm-lock.yaml | 42 ++++++++++++------- 20 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efa4dfc458..52d1ce75fa 100644 --- a/.github/workflows/ci.yml +++ b/.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.18', 24, 26] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1ae0733286..eb73308b50 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,6 +49,17 @@ permissions: jobs: e2e: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The keyless ci.yml matrix runs the MOCK adapter (dsh-llm-replay); the + # real fetch + SSE-streaming adapter path runs ONLY here, so its + # node-version compat is covered nowhere else. Run the real-API suite on + # the engines floor AND the primary line to close that gap. 26 is left to + # the keyless matrix — floor + LTS is the meaningful pair for the live + # network path, and inference is cheap (we are DeepSeek). + node: ['22.18', 24] + name: e2e node ${{ matrix.node }} # Run on every trusted event. Skip untrusted PRs (forks + Dependabot) where # the secret is withheld — they would otherwise hard-fail the preflight. if: >- @@ -62,7 +73,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 24 + node-version: ${{ matrix.node }} - name: Enable corepack (pnpm) run: corepack enable diff --git a/AGENTS.md b/AGENTS.md index c582053122..c97ca15b55 100644 --- a/AGENTS.md +++ b/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.18 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 diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 05c3f648c0..00f10278f2 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with 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 the platform-native `fetch` (Node 22.18), 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. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ab3478a61..2fc2c3cfe9 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 97ca3f6b9fc9653ab658e480e6155fc1e121854f -development.zh.md: e837afb6a01ed4d0c4801886bd6ca6a7602ac573 +development.md: 8f901909264d4405d396782d68c28fbde9b85bfa +development.zh.md: 2b2af080d11b8ea9d9cbf26c62833b4fc00fb6ba diff --git a/docs/development.md b/docs/development.md index 97ca3f6b9f..8f90190926 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ This guide covers the local setup needed to work on DeepSeek Harness and underst ## Prerequisites -- Node.js 24 or newer. The repo declares `node >=24`; CI runs the matrix on Node 24 and 26. +- Node.js 22.18 or newer. The repo declares `node >=22.18`; CI runs the matrix on Node 22.18, 24, and 26. - 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,7 +63,7 @@ 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 matrix on Node 22.18, 24, and 26. ## CI gates diff --git a/docs/development.zh.md b/docs/development.zh.md index e837afb6a0..2b2af080d1 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -6,7 +6,7 @@ ## 前置条件 -- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵。 +- Node.js 22.18 或更新版本。仓库声明 `node >=22.18`;CI 在 Node 22.18、24 和 26 上跑矩阵。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。 - Git。 - 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。 @@ -63,7 +63,7 @@ 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.18、24 和 26 上跑矩阵。 ## CI 门禁 diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5e23a876fb..6b15512503 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -144,6 +144,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 | | [Generated plugin config catalog](implemented/process/2026-07-06-generated-config-catalog.md) | 2026-07-06 | +| [Lower the Node engines floor to 22.18](implemented/process/2026-07-06-node-22-18-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 | diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index a03df16c8c..74b68ca2f3 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -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 the platform-native `fetch` (Node 22.18), 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. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 69b1beb554..52823b8222 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/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.18/24/26 plus a demo smoke test driving the echo-agent end to end. ## Consequences diff --git a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md new file mode 100644 index 0000000000..737bb2e40f --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md @@ -0,0 +1,30 @@ +# RFC: Lower the Node engines floor to 22.18 + +Status: implemented + +## Problem + +The root `engines.node` was `>=24`, which excluded the entire Node 22 LTS line for no runtime reason. The harness has exactly two Node features whose availability gates the floor, and both are satisfied well below Node 24 — so the floor was higher than the code actually requires. Pinning it honestly widens the supported install base (Node 22 LTS is in service until 2027) without weakening any guarantee, provided CI proves the claim on the floor version rather than merely asserting it in a manifest. + +## Decision + +Set `engines.node` to `>=22.18` and treat 22.18 as the tested floor everywhere (CI matrix `['22.18', 24, 26]`, the real-API e2e job on `['22.18', 24]` — floor plus primary line, since the keyless matrix exercises only the mock adapter and the live `fetch`/SSE path runs only in e2e). 22.18 is the *later* of the two feature boundaries the code depends on, so it is the earliest Node version where everything the repo ships and tests runs unflagged: + +- **`node:sqlite` — Node 22.13.** `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement in Node 22.13 (backport of the 23.4 change), so any floor ≥ 22.13 loads it without a flag. +- **Native TypeScript type-stripping — Node 22.18.** The `packages/ui/stdio-agent/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`) with no tsx. Native type-stripping — which makes that work — was unflagged in the 22.x LTS line only in 22.18 (before that it needed `--experimental-strip-types`). This is the binding constraint, so it sets the floor. + +`@types/node` is pinned to the 22.x line (`^22.20.0`) to match the floor: reaching for a Node 23+/24+/25+ API then fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only the 22.18 matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing. + +## Consequences + +- The supported base widens to the Node 22 LTS line, and the `['22.18', 24, 26]` matrix proves it on every push and PR rather than trusting the manifest. +- The built-bin smoke needs no version-conditional flag: at 22.18 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents. +- A future change reaching for a Node 23+ API fails `tsc` immediately (the `@types/node` pin); one reaching for an API added in 22.19/22.20 — inside the 22.x type surface but above the floor — is caught instead by the 22.18 matrix leg. Either way the floor must move in the same change. +- The `vendor/hmr` and `vendor/loader` comments about Node 24 module-cache internals are unaffected — they describe dev-time HMR loader behavior, not the shipped runtime contract, and are pinned vendored source. + +## Alternatives considered + +- **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. 22.18 clears both boundaries with zero test special-casing, and the five-patch gap below it buys nothing real. +- **Keep `>=24`.** Rejected: it excludes Node 22 LTS with no runtime justification once the two boundaries above are known. +- **Matrix `[22, 24, 26]` (latest 22.x) instead of pinning `22.18`.** Rejected: "latest 22.x" drifts upward over time and would silently stop exercising the declared floor. Pinning the floor version is what makes the matrix a proof of the claim rather than a proof of some newer 22.x. +- **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.18 — exactly the "green types, broken product" gap. Pinning `@types/node` to the 22.x line turns that into a compile error everywhere, and the tree already typechecks clean against the Node 22 surface, so the pin is free. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 982c0fcb3c..5be4427b36 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -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. Node matrix `['22.18', 24]` (the `engines` floor plus the primary line): the keyless ci.yml matrix exercises only the MOCK adapter (`dsh-llm-replay`), so the real `fetch` + SSE-streaming adapter path — and its node-version compat — runs nowhere else. Running the real-API suite on both the floor and the primary line closes that gap; 26 is left to the keyless matrix, since floor + LTS is the meaningful pair for the live network path and inference is cheap (we are DeepSeek). `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 diff --git a/package.json b/package.json index dc8f487bd9..54ce68e408 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.7.0", "engines": { - "node": ">=24" + "node": ">=22.18" }, "workspaces": [ "vendor/*", @@ -70,7 +70,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", diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index d7095633c9..89ab74f09c 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/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 targets Node ≥ 22.18 (the root `engines` field), which includes `node:sqlite` unflagged — the module has been available without the `--experimental-sqlite` flag since Node 22.13, so this backend's top-level `import { DatabaseSync } from 'node:sqlite'` loads without a flag on every supported version. 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 diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index cda8b41b1f..38e0170ff5 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/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.18+ — 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', diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 29b183b710..acfca02e78 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/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 the platform-native `fetch` (Node 22.18) 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 diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 40566b4f75..ade27721a4 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/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` (Node 22.18), 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`. diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index f187f90344..8e3f7d8b02 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/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` (Node 22.18), mirroring * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. * * @module @deepseek-ai/dsh-web-search-exa/provider diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index ed72ea82c3..f4a12fb415 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/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` (Node 22.18), 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`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97bcc8b288..0e68ee6fa9 100644 --- a/pnpm-lock.yaml +++ b/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: @@ -2348,6 +2348,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==} @@ -3811,6 +3814,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 +5086,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 @@ -5197,7 +5207,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 +5218,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: @@ -6808,6 +6818,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 +6849,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 +6867,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 +6894,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: From 1c2823c73d751af5373b867cafd188c40bcf5ade Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:16:31 +0800 Subject: [PATCH 17/36] fix(scripts): replace async fs glob with globSync (failed on Node 22.18) --- scripts/doc-typecheck.ts | 5 ++--- scripts/verify-doc-refs.ts | 5 ++--- scripts/verify-md-links.ts | 5 ++--- scripts/verify-md-wrap.ts | 5 ++--- scripts/verify-mermaid.ts | 5 ++--- scripts/verify-package-paths.ts | 5 ++--- scripts/verify-translation-pairing.ts | 5 ++--- scripts/verify-type-equiv.ts | 5 ++--- 8 files changed, 16 insertions(+), 24 deletions(-) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 6f40cccd0a..e57f3710ee 100644 --- a/scripts/doc-typecheck.ts +++ b/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() diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index a53399a272..56be5140c8 100644 --- a/scripts/verify-doc-refs.ts +++ b/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))) diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 2a96cfd0af..d3e80e285e 100644 --- a/scripts/verify-md-links.ts +++ b/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() 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. diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 3ffad3be43..2d8845e030 100644 --- a/scripts/verify-md-wrap.ts +++ b/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() 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. diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 954c246640..3f9af495b3 100644 --- a/scripts/verify-mermaid.ts +++ b/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() 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) diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 7bec754dba..5d2d91982b 100644 --- a/scripts/verify-package-paths.ts +++ b/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() 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)) diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index eea30e4b22..3d80572e7c 100644 --- a/scripts/verify-translation-pairing.ts +++ b/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() 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() diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index c93383e40f..85ccd642d9 100644 --- a/scripts/verify-type-equiv.ts +++ b/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() 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) From 393da2b9836a28eb3ceb43e4ea67a8e9cecc5451 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:45:13 +0800 Subject: [PATCH 18/36] =?UTF-8?q?fix:=20engines=20^22.18.0=20||=20>=3D24.0?= =?UTF-8?q?.0=20=E2=80=94=20exclude=20EOL=20Node=2023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../implemented/process/2026-07-06-node-22-18-floor.md | 10 +++++++--- package.json | 2 +- .../session-persistence-sqlite/README.md | 2 +- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c97ca15b55..6f83dae519 100644 --- a/AGENTS.md +++ b/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 >= 22.18 +pnpm install # pnpm workspaces, node ^22.18 || >=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 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2fc2c3cfe9..9b0bbbd1d6 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 8f901909264d4405d396782d68c28fbde9b85bfa -development.zh.md: 2b2af080d11b8ea9d9cbf26c62833b4fc00fb6ba +development.md: 3acbff05204e6c7f44c6a8a727052a6abf881fab +development.zh.md: 5a02cce00c26baf5c94d4a20a9138c5af89c27c3 diff --git a/docs/development.md b/docs/development.md index 8f90190926..3acbff0520 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ This guide covers the local setup needed to work on DeepSeek Harness and underst ## Prerequisites -- Node.js 22.18 or newer. The repo declares `node >=22.18`; CI runs the matrix on Node 22.18, 24, and 26. +- Node.js `^22.18.0 || >=24.0.0` (22.18+ on the LTS line, or 24+). The Node 23 line is excluded: `node:sqlite` (until 23.4) and native TS type-stripping (until 23.6) are still flagged there, and 23 is non-LTS/EOL. CI runs the matrix on Node 22.18, 24, and 26. - 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. diff --git a/docs/development.zh.md b/docs/development.zh.md index 2b2af080d1..5a02cce00c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -6,7 +6,7 @@ ## 前置条件 -- Node.js 22.18 或更新版本。仓库声明 `node >=22.18`;CI 在 Node 22.18、24 和 26 上跑矩阵。 +- Node.js `^22.18.0 || >=24.0.0`(即 LTS 线的 22.18+,或 24+)。排除 Node 23 线:那里 `node:sqlite`(要到 23.4)和原生 TS 类型剥离(要到 23.6)仍需 flag,且 23 是非 LTS、已 EOL。CI 在 Node 22.18、24 和 26 上跑矩阵。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。 - Git。 - 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。 diff --git a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md index 737bb2e40f..128d06246f 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md @@ -8,10 +8,12 @@ The root `engines.node` was `>=24`, which excluded the entire Node 22 LTS line f ## Decision -Set `engines.node` to `>=22.18` and treat 22.18 as the tested floor everywhere (CI matrix `['22.18', 24, 26]`, the real-API e2e job on `['22.18', 24]` — floor plus primary line, since the keyless matrix exercises only the mock adapter and the live `fetch`/SSE path runs only in e2e). 22.18 is the *later* of the two feature boundaries the code depends on, so it is the earliest Node version where everything the repo ships and tests runs unflagged: +Set `engines.node` to `^22.18.0 || >=24.0.0` (Node 22.18+ on the LTS line, or 24+) and test it on the CI matrix `['22.18', 24, 26]` — the real-API e2e job on `['22.18', 24]` (floor plus primary line, since the keyless matrix exercises only the mock adapter and the live `fetch`/SSE path runs only in e2e). Two Node features gate the range, each with its own LTS-line and Current-line unflag point: -- **`node:sqlite` — Node 22.13.** `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement in Node 22.13 (backport of the 23.4 change), so any floor ≥ 22.13 loads it without a flag. -- **Native TypeScript type-stripping — Node 22.18.** The `packages/ui/stdio-agent/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`) with no tsx. Native type-stripping — which makes that work — was unflagged in the 22.x LTS line only in 22.18 (before that it needed `--experimental-strip-types`). This is the binding constraint, so it sets the floor. +- **`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`. + +On the 22.x line both features clear at **22.18** (the later of 22.13/22.18), so `^22.18.0` is the LTS floor. The range is **disjoint** rather than an open `>=22.18` because the Node **23.0–23.5** window still has at least one feature flagged (sqlite until 23.4, stripping until 23.6): `>=22.18` would advertise support there, where the sqlite backend throws `ERR_UNKNOWN_BUILTIN_MODULE` at load. Node 23 is non-LTS and already end-of-life, so rather than carve out `>=23.6` the range skips the whole line and resumes at `>=24.0.0` — the same shape several of the repo's own dependencies already declare (`^22.18.0 || >=24.11.0`). `@types/node` is pinned to the 22.x line (`^22.20.0`) to match the floor: reaching for a Node 23+/24+/25+ API then fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only the 22.18 matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing. @@ -26,5 +28,7 @@ Set `engines.node` to `>=22.18` and treat 22.18 as the tested floor everywhere ( - **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. 22.18 clears both boundaries with zero test special-casing, and the five-patch gap below it buys nothing real. - **Keep `>=24`.** Rejected: it excludes Node 22 LTS with no runtime justification once the two boundaries above are known. +- **Open-ended `>=22.18`.** 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, so the sqlite backend throws at load. The disjoint `^22.18.0 || >=24.0.0` matches the real runtime boundary. +- **Include Node 23.6+ (`^22.18.0 || >=23.6.0`).** Rejected: 23.6+ does run both features unflagged, but Node 23 is end-of-life — advertising a dead release line adds a range term (and, to back it, a CI leg) for a runtime no deployment should use. 24 is the meaningful resumption point, and the 22.18 and 24 legs already bracket the same unflagged code paths. - **Matrix `[22, 24, 26]` (latest 22.x) instead of pinning `22.18`.** Rejected: "latest 22.x" drifts upward over time and would silently stop exercising the declared floor. Pinning the floor version is what makes the matrix a proof of the claim rather than a proof of some newer 22.x. - **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.18 — exactly the "green types, broken product" gap. Pinning `@types/node` to the 22.x line turns that into a compile error everywhere, and the tree already typechecks clean against the Node 22 surface, so the pin is free. diff --git a/package.json b/package.json index 54ce68e408..60770f0d1f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.7.0", "engines": { - "node": ">=22.18" + "node": "^22.18.0 || >=24.0.0" }, "workspaces": [ "vendor/*", diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 89ab74f09c..f3601140d2 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/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 ≥ 22.18 (the root `engines` field), which includes `node:sqlite` unflagged — the module has been available without the `--experimental-sqlite` flag since Node 22.13, so this backend's top-level `import { DatabaseSync } from 'node:sqlite'` loads without a flag on every supported version. 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.18.0 || >=24.0.0` (Node 22.18+ or 24+). `node:sqlite` ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on; the range deliberately excludes the Node 23.0–23.3 window, where the module is still flagged and this backend's top-level `import { DatabaseSync } from 'node:sqlite'` would throw at load. 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 From 6edce91735423968efe7633b6468ecc86efb41fa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:24:57 +0800 Subject: [PATCH 19/36] ci: e2e stay on Node 24 --- .github/workflows/e2e.yml | 14 ++------------ .../process/2026-07-06-node-22-18-floor.md | 2 +- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index eb73308b50..c0371947fd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,17 +49,7 @@ permissions: jobs: e2e: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - # The keyless ci.yml matrix runs the MOCK adapter (dsh-llm-replay); the - # real fetch + SSE-streaming adapter path runs ONLY here, so its - # node-version compat is covered nowhere else. Run the real-API suite on - # the engines floor AND the primary line to close that gap. 26 is left to - # the keyless matrix — floor + LTS is the meaningful pair for the live - # network path, and inference is cheap (we are DeepSeek). - node: ['22.18', 24] - name: e2e node ${{ matrix.node }} + 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: >- @@ -73,7 +63,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node }} + node-version: 24 - name: Enable corepack (pnpm) run: corepack enable diff --git a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md index 128d06246f..8e0c736fd9 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md @@ -8,7 +8,7 @@ The root `engines.node` was `>=24`, which excluded the entire Node 22 LTS line f ## Decision -Set `engines.node` to `^22.18.0 || >=24.0.0` (Node 22.18+ on the LTS line, or 24+) and test it on the CI matrix `['22.18', 24, 26]` — the real-API e2e job on `['22.18', 24]` (floor plus primary line, since the keyless matrix exercises only the mock adapter and the live `fetch`/SSE path runs only in e2e). Two Node features gate the range, each with its own LTS-line and Current-line unflag point: +Set `engines.node` to `^22.18.0 || >=24.0.0` (Node 22.18+ on the LTS line, or 24+) and test it on the keyless CI matrix `['22.18', 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 range, each with its own LTS-line and Current-line unflag point: - **`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`. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 5be4427b36..1b348b1515 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -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. Node matrix `['22.18', 24]` (the `engines` floor plus the primary line): the keyless ci.yml matrix exercises only the MOCK adapter (`dsh-llm-replay`), so the real `fetch` + SSE-streaming adapter path — and its node-version compat — runs nowhere else. Running the real-API suite on both the floor and the primary line closes that gap; 26 is left to the keyless matrix, since floor + LTS is the meaningful pair for the live network path and inference is cheap (we are DeepSeek). `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.18/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 From 92b5eccc961e350ca6a543453d6ac9661708f5eb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:39:04 +0800 Subject: [PATCH 20/36] build: upgrade to 22.19 for deps --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- docs/core-data-structures/web.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 6 +-- docs/development.zh.md | 6 +-- docs/rfc/INDEX.md | 2 +- .../2026-06-24-web-capability-seam.md | 2 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-07-06-node-22-18-floor.md | 34 ----------------- .../process/2026-07-06-node-engine-floor.md | 37 +++++++++++++++++++ .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- package.json | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../ui/stdio-agent/tests/built-bin.e2e.ts | 2 +- packages/web/web-fetch-local/src/provider.ts | 2 +- .../web/web-search-deepseek/src/provider.ts | 2 +- packages/web/web-search-exa/src/provider.ts | 2 +- .../web/web-search-perplexity/src/provider.ts | 2 +- 19 files changed, 59 insertions(+), 56 deletions(-) delete mode 100644 docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md create mode 100644 docs/rfc/implemented/process/2026-07-06-node-engine-floor.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d1ce75fa..e15f344653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['22.18', 24, 26] + node: ['22.19', 24, 26] steps: - uses: actions/checkout@v6 diff --git a/AGENTS.md b/AGENTS.md index 6f83dae519..a6331c0b89 100644 --- a/AGENTS.md +++ b/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 ^22.18 || >=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 diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 00f10278f2..f6bd6406ab 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 22.18), 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. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9b0bbbd1d6..c3926e69bd 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 3acbff05204e6c7f44c6a8a727052a6abf881fab -development.zh.md: 5a02cce00c26baf5c94d4a20a9138c5af89c27c3 +development.md: 3cb96968ccbe291c3cd94937c88cd414f18f2166 +development.zh.md: 98f3ad4cd12ecd73fd8e23ad48278d8bc23d2496 diff --git a/docs/development.md b/docs/development.md index 3acbff0520..3cb96968cc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ This guide covers the local setup needed to work on DeepSeek Harness and underst ## Prerequisites -- Node.js `^22.18.0 || >=24.0.0` (22.18+ on the LTS line, or 24+). The Node 23 line is excluded: `node:sqlite` (until 23.4) and native TS type-stripping (until 23.6) are still flagged there, and 23 is non-LTS/EOL. CI runs the matrix on Node 22.18, 24, and 26. +- Node.js `^22.19.0 || >=24.0.0` (22.19+ on the LTS line, or 24+). The LTS floor matches `@earendil-works/pi-ai`'s Node 22.19 dependency floor. The Node 23 line is excluded: `node:sqlite` (until 23.4) and native TS type-stripping (until 23.6) are still flagged there, and 23 is non-LTS/EOL. CI runs the compatibility matrix on Node 22.19, 24, and 26. - 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 22.18, 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`. diff --git a/docs/development.zh.md b/docs/development.zh.md index 5a02cce00c..98f3ad4cd1 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -6,7 +6,7 @@ ## 前置条件 -- Node.js `^22.18.0 || >=24.0.0`(即 LTS 线的 22.18+,或 24+)。排除 Node 23 线:那里 `node:sqlite`(要到 23.4)和原生 TS 类型剥离(要到 23.6)仍需 flag,且 23 是非 LTS、已 EOL。CI 在 Node 22.18、24 和 26 上跑矩阵。 +- Node.js `^22.19.0 || >=24.0.0`(即 LTS 线的 22.19+,或 24+)。LTS floor 匹配 `@earendil-works/pi-ai` 的 Node 22.19 依赖 floor。排除 Node 23 线:那里 `node:sqlite`(要到 23.4)和原生 TS 类型剥离(要到 23.6)仍需 flag,且 23 是非 LTS、已 EOL。CI 在 Node 22.19、24 和 26 上跑兼容性矩阵。 - 启用了 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 22.18、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`。 diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 6b15512503..483eb9de2a 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -144,7 +144,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 | | [Generated plugin config catalog](implemented/process/2026-07-06-generated-config-catalog.md) | 2026-07-06 | -| [Lower the Node engines floor to 22.18](implemented/process/2026-07-06-node-22-18-floor.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 | diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 74b68ca2f3..660b9821ff 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -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 22.18), 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. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 52823b8222..505cea90ff 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/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 22.18/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 diff --git a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md b/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md deleted file mode 100644 index 8e0c736fd9..0000000000 --- a/docs/rfc/implemented/process/2026-07-06-node-22-18-floor.md +++ /dev/null @@ -1,34 +0,0 @@ -# RFC: Lower the Node engines floor to 22.18 - -Status: implemented - -## Problem - -The root `engines.node` was `>=24`, which excluded the entire Node 22 LTS line for no runtime reason. The harness has exactly two Node features whose availability gates the floor, and both are satisfied well below Node 24 — so the floor was higher than the code actually requires. Pinning it honestly widens the supported install base (Node 22 LTS is in service until 2027) without weakening any guarantee, provided CI proves the claim on the floor version rather than merely asserting it in a manifest. - -## Decision - -Set `engines.node` to `^22.18.0 || >=24.0.0` (Node 22.18+ on the LTS line, or 24+) and test it on the keyless CI matrix `['22.18', 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 range, each with its own LTS-line and Current-line unflag point: - -- **`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`. - -On the 22.x line both features clear at **22.18** (the later of 22.13/22.18), so `^22.18.0` is the LTS floor. The range is **disjoint** rather than an open `>=22.18` because the Node **23.0–23.5** window still has at least one feature flagged (sqlite until 23.4, stripping until 23.6): `>=22.18` would advertise support there, where the sqlite backend throws `ERR_UNKNOWN_BUILTIN_MODULE` at load. Node 23 is non-LTS and already end-of-life, so rather than carve out `>=23.6` the range skips the whole line and resumes at `>=24.0.0` — the same shape several of the repo's own dependencies already declare (`^22.18.0 || >=24.11.0`). - -`@types/node` is pinned to the 22.x line (`^22.20.0`) to match the floor: reaching for a Node 23+/24+/25+ API then fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only the 22.18 matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing. - -## Consequences - -- The supported base widens to the Node 22 LTS line, and the `['22.18', 24, 26]` matrix proves it on every push and PR rather than trusting the manifest. -- The built-bin smoke needs no version-conditional flag: at 22.18 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents. -- A future change reaching for a Node 23+ API fails `tsc` immediately (the `@types/node` pin); one reaching for an API added in 22.19/22.20 — inside the 22.x type surface but above the floor — is caught instead by the 22.18 matrix leg. Either way the floor must move in the same change. -- The `vendor/hmr` and `vendor/loader` comments about Node 24 module-cache internals are unaffected — they describe dev-time HMR loader behavior, not the shipped runtime contract, and are pinned vendored source. - -## Alternatives considered - -- **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. 22.18 clears both boundaries with zero test special-casing, and the five-patch gap below it buys nothing real. -- **Keep `>=24`.** Rejected: it excludes Node 22 LTS with no runtime justification once the two boundaries above are known. -- **Open-ended `>=22.18`.** 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, so the sqlite backend throws at load. The disjoint `^22.18.0 || >=24.0.0` matches the real runtime boundary. -- **Include Node 23.6+ (`^22.18.0 || >=23.6.0`).** Rejected: 23.6+ does run both features unflagged, but Node 23 is end-of-life — advertising a dead release line adds a range term (and, to back it, a CI leg) for a runtime no deployment should use. 24 is the meaningful resumption point, and the 22.18 and 24 legs already bracket the same unflagged code paths. -- **Matrix `[22, 24, 26]` (latest 22.x) instead of pinning `22.18`.** Rejected: "latest 22.x" drifts upward over time and would silently stop exercising the declared floor. Pinning the floor version is what makes the matrix a proof of the claim rather than a proof of some newer 22.x. -- **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.18 — exactly the "green types, broken product" gap. Pinning `@types/node` to the 22.x line turns that into a compile error everywhere, and the tree already typechecks clean against the Node 22 surface, so the pin is free. diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md new file mode 100644 index 0000000000..72c9f09eda --- /dev/null +++ b/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. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 1b348b1515..e567c4ff24 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -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 primary line): these tests exercise API integration, not node-version compatibility, which ci.yml's Node 22.18/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. +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 diff --git a/package.json b/package.json index 60770f0d1f..6710b6bf44 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.7.0", "engines": { - "node": "^22.18.0 || >=24.0.0" + "node": "^22.19.0 || >=24.0.0" }, "workspaces": [ "vendor/*", diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index f3601140d2..c14d3a70ea 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/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's `engines.node` is `^22.18.0 || >=24.0.0` (Node 22.18+ or 24+). `node:sqlite` ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on; the range deliberately excludes the Node 23.0–23.3 window, where the module is still flagged and this backend's top-level `import { DatabaseSync } from 'node:sqlite'` would throw at load. 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 diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 38e0170ff5..7b84fad65b 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -77,7 +77,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi await symlink(abs, target) } // The example's mock model + echo tool are example-local TS plugins (Node - // 22.18+ — the engines floor — strips types natively, so plain `node` loads + // 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 }) diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index acfca02e78..2175a62770 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/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 22.18) 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 diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index ade27721a4..4c9679eb8e 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/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 22.18), 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`. diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 8e3f7d8b02..6a764fae93 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/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 22.18), 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 diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index f4a12fb415..506a03be59 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/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 22.18), 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`. From 46a719bba6f7e1e3f75e997f4f804fba28e55b2c Mon Sep 17 00:00:00 2001 From: pku-xht <170163488+pku-xht@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:40:07 +0000 Subject: [PATCH 21/36] docs(rfc): propose Claude Code and Codex subagent backends Out-of-process delegation to external coding agents as two new subagent seam backends, exposed as subagent_claude_code / subagent_codex tools. Verified against @anthropic-ai/claude-agent-sdk 0.3.202 and codex CLI 0.142.5 via keyless spikes; includes the dsh-subagent-process extraction plan, isolation/permission stances, and tiered test coverage. --- docs/rfc/INDEX.md | 1 + ...claude-code-and-codex-subagent-backends.md | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5e23a876fb..c692c6ffea 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,6 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | +| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md new file mode 100644 index 0000000000..3de3c7d9be --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -0,0 +1,87 @@ +# RFC: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents) + +Status: proposed + +## Problem + +The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. + +## Proposal + +Two sibling provider packages, structural variants of the ACP backend, plus one extraction: + +- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. +- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. +- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. + +Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. + +## Verified interface facts (pinned versions) + +Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior. + +**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here. + +**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted. + +- Lifecycle: `initialize{clientInfo}` + `initialized` → `thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`. +- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions. +- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn. +- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all. + +## Isolation and credentials + +Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. + +## Permission and approval policy + +Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. + +## StopReason mapping + +Claude Code: `success` → `completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries` → `error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed` → `completed`; `interrupted` → `aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`, any other `failed` → `error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result. + +Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent. + +## Testing + +Named at every tier per the root AGENTS.md rule, and de-risked up front: + +- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. +- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. +- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. + +## Alternatives considered + +### Why not the official `@openai/codex-sdk` instead of a hand-rolled client? + +The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have. + +### Why not a model-visible `subagent_type` parameter (one Task-style tool)? + +Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate RFC against the tool, not the backends. + +### Why not login-state credentials and the user's own config? + +Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately. + +### Why not a driver-injection seam for the Claude Code keyless tests? + +Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working. + +### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend? + +Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this RFC exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points. + +## Acceptance criteria + +On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite. + +## Risks + +- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above). +- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch). +- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package. +- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion. +- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe. +- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals. From 995ba1f1057de8769f158d1253cec112c960092c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:03:14 +0800 Subject: [PATCH 22/36] docs: tighten development onboarding wording --- docs/AGENTS.md | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 4 ++-- docs/development.zh.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fed058f60a..771e30dcc6 100644 --- a/docs/AGENTS.md +++ b/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) | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index c3926e69bd..06d0ff366c 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 3cb96968ccbe291c3cd94937c88cd414f18f2166 -development.zh.md: 98f3ad4cd12ecd73fd8e23ad48278d8bc23d2496 +development.md: 6d5bc28f412a888e239229305b983ffac08c737a +development.zh.md: eaa600d3a0a478d96848eb6866c06913a54fa428 diff --git a/docs/development.md b/docs/development.md index 3cb96968cc..6d5bc28f41 100644 --- a/docs/development.md +++ b/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 `^22.19.0 || >=24.0.0` (22.19+ on the LTS line, or 24+). The LTS floor matches `@earendil-works/pi-ai`'s Node 22.19 dependency floor. The Node 23 line is excluded: `node:sqlite` (until 23.4) and native TS type-stripping (until 23.6) are still flagged there, and 23 is non-LTS/EOL. CI runs the compatibility matrix on Node 22.19, 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. diff --git a/docs/development.zh.md b/docs/development.zh.md index 98f3ad4cd1..eaa600d3a0 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,11 +2,11 @@ [English](development.md) | 中文 -本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁。 +本文面向参与项目开发的贡献者,帮助你上手本地环境、日常工作流和 CI 流程。相关设计考量和技术取舍参见 RFC,不在这里展开。 ## 前置条件 -- Node.js `^22.19.0 || >=24.0.0`(即 LTS 线的 22.19+,或 24+)。LTS floor 匹配 `@earendil-works/pi-ai` 的 Node 22.19 依赖 floor。排除 Node 23 线:那里 `node:sqlite`(要到 23.4)和原生 TS 类型剥离(要到 23.6)仍需 flag,且 23 是非 LTS、已 EOL。CI 在 Node 22.19、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 测试。 From 589259e70c1ae1f57a421bbedc9c1d617df53b82 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 7 Jul 2026 19:17:16 +0800 Subject: [PATCH 23/36] simplify pre-push skill guidance --- .agents/skills/dsh-pre-push-checks/SKILL.md | 30 ++++----------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 5f3d84bd83..7c0d629fff 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, replying that checks pass, or bypassing a local hook on a deepseek-harness branch. Guides Codex to run the right local gates for the touched surface so CI is unlikely to fail after push, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes. +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 @@ -25,7 +25,7 @@ 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 and committing the merge. Do not push a conflict-resolution commit that has only typecheck/lint evidence. +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 @@ -67,27 +67,7 @@ Run a targeted test first for the changed package, but never use targeted tests ## 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: - -```sh -pnpm run constraints -pnpm run typecheck -pnpm run lint -pnpm run doc-sync -pnpm run verify-module-graph -pnpm run test:coverage -pnpm run test:snapshot -pnpm run build -pnpm run hygiene -out=$(printf 'echo ci smoke\n' | pnpm run demo:echo 2>&1) -printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' -printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' -ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null -rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts -``` - -The `demo:echo` smoke validates the mock-model REPL path and leaves a session log; assert both transcript lines and then remove `.sessions`. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior. +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 @@ -104,8 +84,8 @@ Known pattern to watch for: Linux CI and macOS local behavior can differ for she ## Push Procedure -1. Commit only after the relevant gates pass. -2. Let the normal pre-commit hook run. If it changes files, inspect and amend with a new commit rather than hiding the change. +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. From 5e4ac5e4726086a9b042e34e37f8863b44af2f94 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:30:34 +0800 Subject: [PATCH 24/36] fix review findings: CI leaf-gate wiring, heritage return surface, AGENTS.md self-containedness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-gates.ts docSyncLeafGates() gains verify-export-jsdoc — CI lanes and the pre-push hook execute this leaf list, not the doc-sync npm script, so the gate was previously unenforced there (proven by SessionForkErrorCode landing undocumented via a master merge while checks stayed green; now documented). Same wiring gap fixed for master's verify-config-catalog, which was also missing from the list. - The heritage exemption now recovers the base's return surface: a void base return carried no @returns duty, so an override returning a concrete result documents it itself (annotated overrides run the standard check; unannotated ones are classified by the checker so faithful void overrides need no boilerplate annotation). Three new negative-path tests pin it; RFC and module doc updated. - AGENTS.md states each principle inline instead of citing RFCs (eight citations removed; high-level doc links kept) and the editing section now carries the self-containedness rule. - Generated catalogs/graphs regenerated for the shifted line pointers. --- AGENTS.md | 18 ++-- docs/cordis-catalog/services.md | 2 +- .../2026-07-06-export-surface-jsdoc-gate.md | 2 +- .../agent/tests/verify-export-jsdoc.spec.ts | 65 +++++++++++++++ packages/core/session/src/index.ts | 8 ++ scripts/run-gates.ts | 2 + scripts/verify-export-jsdoc.ts | 82 ++++++++++++++----- 7 files changed, 148 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5c6fcd5c8..57d46556fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,21 +83,21 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header ([catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)). +- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise; mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header. - **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). -- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event ([reconstructability RFC](docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). +- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. -- **Capability seams are three packages** — interface / implementation / consumer ([capability seams](docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)); don't split preemptively. +- **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. -- **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string` ([branded IDs](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)). +- **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction. -- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR ([worked example](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md)). -- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/` ([worked example](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR. +- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/`. - **Testing policy** — [docs/testing.md](docs/testing.md). Transcript/UX changes need snapshots or a PR note. Snapshot fixtures must replay on macOS/Linux; avoid GNU/BSD-only commands (e.g. `sed -i`); fix fixtures, not normalizers. -- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([render-intent RFC](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), [cookbook](docs/cookbook/adding-a-tool.md)). +- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise. - **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). @@ -109,13 +109,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR ## Type safety and documentation -Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones — heritage-declared members, plugin-protocol slots, and constructors exempt ([export-gate RFC](docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md)). Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example). +Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones. Heritage-declared members, plugin-protocol slots, and constructors are exempt — their docs' one home is the seam declaration, the framework protocol, and the class doc respectively. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example). Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. This file is budget-gated (`verify-doc-budgets`): condense first if it is possible without sacrificing clarity; truly needed additions may justify a ceiling raise. +`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. Keep it self-contained: state each principle inline instead of citing RFCs (they stay discoverable via the RFC index); linking high-level docs — architecture, testing, cookbooks — is fine. This file is budget-gated (`verify-doc-budgets`): condense first if it is possible without sacrificing clarity; truly needed additions may justify a ceiling raise. ## Vendoring policy diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4752ce2891..1d929542b8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -164,7 +164,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:397`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 54c5c54e44..2e98808426 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -22,7 +22,7 @@ The contract by declaration kind: Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): -- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where the override grows surface the base never documented: a protected-only base member does not exempt a public override, and parameters the base never names keep their `@param` duty (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter). This is the one question the walk asks the TYPE CHECKER (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). +- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where the override grows surface the base never documented: a protected-only base member does not exempt a public override, parameters the base never names keep their `@param` duty (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter), and a concrete result above a void base return keeps its `@returns` duty (an unannotated override's inferred return is classified by the checker, so a faithful void override needs no boilerplate annotation). Heritage lookups and that one return classification are the walk's only TYPE CHECKER questions (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). - **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics. - **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story. diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index ae7dd1bf81..b699a72765 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -487,4 +487,69 @@ export class Impl extends Base { } `))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)]) }) + + it('revives the @returns duty when an override grows a concrete result over a void base', () => { + const voidBase = ` +/** Seam. */ +export abstract class Base { + /** Do it (fire-and-forget). */ + abstract run(): void +} +` + expect(collectExportJsdocViolations(make(`${voidBase} +/** Impl. */ +export class Impl extends Base { + override run(): number { return 1 } +} +`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @returns \(return type: number\)\./)]) + expect(collectExportJsdocViolations(make(`${voidBase} +/** Impl. */ +export class Impl extends Base { + /** + * Do it and count. + * @returns how many were done. + */ + override run(): number { return 1 } +} +`))).toEqual([]) + }) + + it('classifies an unannotated override return over a void base via the checker', () => { + const voidBase = ` +/** Seam. */ +export abstract class Base { + /** Do it (fire-and-forget). */ + abstract run(): void +} +` + expect(collectExportJsdocViolations(make(`${voidBase} +/** Impl. */ +export class Impl extends Base { + override run() { return 1 } +} +`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* non-void result its heritage declaration does not document/)]) + expect(collectExportJsdocViolations(make(`${voidBase} +/** Impl (faithful void, no annotation needed). */ +export class Impl extends Base { + override run() {} +} +`))).toEqual([]) + }) + + it('keeps the full exemption when the base return already carries the @returns duty', () => { + expect(collectExportJsdocViolations(make(` +/** Seam. */ +export abstract class Base { + /** + * Count things. + * @returns the count. + */ + abstract run(): number +} +/** Impl. */ +export class Impl extends Base { + override run(): number { return 1 } +} +`))).toEqual([]) + }) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b98bcd8d84..cd9e1828b2 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -373,6 +373,14 @@ export class Session { /** A fork source: either the live session object or its live store id. */ export type SessionForkSource = Session | SessionId +/** + * Rejection codes for session forking: the fork source id is unknown to the + * live store (`SESSION_NOT_FOUND`) or names a session object that is not the + * store's live instance (`SESSION_NOT_LIVE`); the requested child id is + * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous + * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a + * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`). + */ export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 195e188858..6c844f842a 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -257,7 +257,9 @@ function docSyncLeafGates(): Gate[] { return [ pnpmScript('doc-typecheck', 'doc-typecheck'), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), + pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), + pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }), pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 46e2f82f2b..779888ab90 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -36,9 +36,11 @@ * the doc's one home, the IDE inherits it, and re-documenting every * implementation invites drift — UNLESS the override grows surface the * base never documented: a protected-only base member does not exempt a - * public override, and parameters the base never names keep their `@param` - * duty. This is the one question the walk asks the TYPE CHECKER (heritage - * members live across package boundaries); everything else is pure AST. + * public override, parameters the base never names keep their `@param` + * duty, and a concrete result above a void base return keeps its + * `@returns` duty. Heritage members (and classifying an unannotated + * override's inferred return above a void base) are the questions the walk + * asks the TYPE CHECKER; everything else is pure AST. * Constructors are exempt like the cordis gate's: plugin classes are * framework-constructed, and the class doc owns the story. * - Exported interfaces, type aliases, enums: description prose on the @@ -165,29 +167,31 @@ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'r * is the doc's one home (the IDE inherits it on hover) and the member needs no * doc of its own — EXCEPT where the override grows public surface the base * never documented: a base member that is protected on every declaration does - * not exempt a public override (consumers could not call it before), and + * not exempt a public override (consumers could not call it before); * parameters the base never names keep their own `@param` duty (the caller * reads the seam doc, which cannot describe them; an underscore-prefixed * rename of a base parameter — the deliberately-unused marker — is the same - * parameter, not new surface). Static members are looked - * up on the base CONSTRUCTOR type (only an `extends` expression has one; an - * unresolvable or interface expression yields no property and therefore no - * exemption). + * parameter, not new surface); and a void base return carried no `@returns` + * duty, so an override returning a concrete result documents it itself. + * Static members are looked up on the base CONSTRUCTOR type (only an + * `extends` expression has one; an unresolvable or interface expression + * yields no property and therefore no exemption). * @param cls - the class whose heritage to search. * @param name - the member name to look up. * @param staticSide - whether to search the constructor side instead of the instance side. * @param checker - the program's type checker. * @returns null when no exemption applies; otherwise the parameter names the - * base declarations carry (`baseParams: null` means the base's parameters are - * not syntactically recoverable — a complex heritage type — and the member is - * exempt in full). + * base declarations carry (`baseParams: null` when not syntactically + * recoverable — a complex heritage type — exempting all parameters) plus + * whether every recoverable base return annotation is `void`-like + * (`baseVoidReturn: null` when none is recoverable, exempting the result). */ function heritageExemption( cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker, -): { baseParams: Set | null } | null { +): { baseParams: Set | null; baseVoidReturn: boolean | null } | null { const isProtected = (d: ts.Declaration): boolean => (ts.canHaveModifiers(d) ? ts.getModifiers(d) : undefined)?.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false for (const clause of cls.heritageClauses ?? []) { @@ -198,24 +202,50 @@ function heritageExemption( const decls = prop.declarations ?? [] if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface let baseParams: Set | null = null + let baseVoidReturn: boolean | null = null for (const d of decls) { let params: readonly ts.ParameterDeclaration[] | undefined - if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) params = d.parameters - else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) { + let returnType: ts.TypeNode | undefined + if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) { + params = d.parameters + returnType = d.type + } else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) { params = d.type.parameters + returnType = d.type.type } else continue baseParams ??= new Set() // Leading underscores are the deliberately-unused marker (eslint // argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the // same parameter, so compare underscore-stripped on both sides. for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, '')) + if (returnType !== undefined) { + const voidish = /^(void|Promise)$/.test(returnType.getText(d.getSourceFile()).replace(/\s+/g, ' ')) + baseVoidReturn = (baseVoidReturn ?? true) && voidish + } } - return { baseParams } + return { baseParams, baseVoidReturn } } } return null } +/** + * True when a method's INFERRED return type is void-like (void, undefined, + * never, or a promise of one) — the one return the walk asks the checker to + * classify: an unannotated override above a void heritage member, where + * demanding an annotation just to prove faithfulness would be boilerplate. + * @param m - a method declaration with no return type annotation. + * @param checker - the program's type checker. + * @returns true when the inferred result carries nothing to document. + */ +function inferredReturnIsVoidish(m: ts.MethodDeclaration, checker: ts.TypeChecker): boolean { + const sig = checker.getSignatureFromDeclaration(m) + if (sig === undefined) return true // no callable signature: nothing classifiable to document + const returned = checker.getReturnTypeOfSignature(sig) + const awaited = checker.getAwaitedType(returned) ?? returned + return (awaited.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0 +} + /** * Check description-prose presence for one labeled declaration: JSDoc must * exist and carry prose above its block tags. @@ -285,17 +315,29 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void { if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})` if (exemption !== null) { - // The heritage declaration owns prose and @returns; parameters the - // base never names — including binding patterns, which no base - // declaration can name — are new surface and keep their @param duty. + const raw = rawJsDoc(w.text, m) + // The heritage declaration owns the prose; parameters the base never + // names — including binding patterns, which no base declaration can + // name — are new surface and keep their @param duty. const base = exemption.baseParams const inBase = (p: ts.ParameterDeclaration): boolean => base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, '')) if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) { - const { params } = parseTags(rawJsDoc(w.text, m)) - checkParams(where, 'export', m.parameters, params, w.sf, + checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf, p => thisReceiver(p) || inBase(p), w.violations) } + // A void base return carried no @returns duty, so an override growing + // a concrete result documents it itself. An annotated override runs + // the standard check; an inferred one is classified by the checker + // (this branch is already the checker's domain), so a faithful void + // override stays exempt without a boilerplate annotation. + if (exemption.baseVoidReturn === true) { + if (m.type !== undefined) { + checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations) + } else if (!inferredReturnIsVoidish(m, w.checker)) { + w.violations.push(`${where} returns a non-void result its heritage declaration does not document; annotate the return type and add @returns.`) + } + } continue } checkFunctionLike(where, rawJsDoc(w.text, m), m.parameters, m.type, false, w) From 72933ec558f6036f540ff5be489ce0beb1e58f70 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:18:01 +0800 Subject: [PATCH 25/36] refactor(system-prompt): rename TOOL_ORDER_REST from '...' to '' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-dot rest entry reads as elision in a cordis.yml; the spelled-out sentinel says what lands there. The literal now appears once in code (the constant) and once in the value-pinning test; every other reference — the forwarding tests included — imports TOOL_ORDER_REST, which adds the dsh-system-prompt devDependency to the two app packages. Review follow-up on #196. --- docs/config-catalog.md | 4 ++-- .../feature/2026-07-06-explicit-tool-order.md | 6 +++--- .../core/agent-core/tests/agent-core.spec.ts | 3 ++- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/src/index.ts | 16 +++++++------- .../system-prompt/tests/tool-order.spec.ts | 10 ++++----- packages/ui/acp-agent/README.md | 2 +- packages/ui/acp-agent/package.json | 1 + packages/ui/acp-agent/tests/acp-agent.spec.ts | 3 ++- packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/package.json | 1 + .../ui/stdio-agent/tests/stdio-agent.spec.ts | 3 ++- pnpm-lock.yaml | 21 +++++++++++++++++-- 13 files changed, 48 insertions(+), 26 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0254ea7267..e0e7e6bff6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -560,8 +560,8 @@ export interface Config { * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed * tools take their listed position, names with no registered tool are * ignored, and tools absent from the list are inserted at the - * {@link TOOL_ORDER_REST} (`'...'`) entry in lexicographic name order. A - * configured list must contain `'...'` exactly once and no duplicate names — + * {@link TOOL_ORDER_REST} (`''`) entry in lexicographic name order. A + * configured list must contain the rest entry exactly once and no duplicate names — * anything else throws at load; a bad order config must never reach a * model request. When omitted, tools are ordered lexicographically by name. * Applied to the tools {@link SystemPrompt.assemble} collects, BEFORE the diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 89226788dc..c4ae9f65fe 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -10,12 +10,12 @@ The order of the tool list a model call carries — `request/header.tools` on th The system-prompt assembly owns the canonical model-facing tool order, exactly where it already owns section order: -- **One config key on `dsh-system-prompt`.** `toolOrder?: string[]` names tools in the exact order to send. A listed tool takes its listed position; a listed name with no registered tool is ignored (a deployment may list optional tools it does not always load); tools absent from the list are inserted at the `'...'` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among themselves. The list must contain `'...'` exactly once and no duplicate names — violations throw from the service constructor, failing the fiber at load, never mid-conversation. When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent) — determinism requires no configuration. +- **One config key on `dsh-system-prompt`.** `toolOrder?: string[]` names tools in the exact order to send. A listed tool takes its listed position; a listed name with no registered tool is ignored (a deployment may list optional tools it does not always load); tools absent from the list are inserted at the `''` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among themselves. The list must contain the rest entry exactly once and no duplicate names — violations throw from the service constructor, failing the fiber at load, never mid-conversation. When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent) — determinism requires no configuration. - **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 service surface and no 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 `'...'`), so every schema on the chain forces the default to `undefined`. +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 @@ -25,7 +25,7 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - **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. +- **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. ## Consequences diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 9d7534ab4e..818da77311 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/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' @@ -68,7 +69,7 @@ describe('dsh-agent-core bundle', () => { }) it('forwards toolOrder to the system-prompt assembly', async () => { - const ctx = await mount({ toolOrder: ['zulu', '...'] }) + 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']) { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 0e4467322f..1690b09ba6 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -7,7 +7,7 @@ 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 `'...'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, names with no registered tool are ignored, unlisted tools land at `'...'` 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. A list without exactly one `'...'`, or with duplicates, throws at load. 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). | +| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, names with no registered tool are ignored, 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. A list without exactly one rest entry, or with duplicates, throws at load. 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`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6eb14f589c..cce43ab7e4 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -117,11 +117,11 @@ const GROUP_AT = /^\{\{([^{}]*)\}\}/ * Deliberately not a valid model-facing tool name, so it can never collide * with a real tool. */ -export const TOOL_ORDER_REST = '...' +export const TOOL_ORDER_REST = '' /** - * Validate a configured tool-order list at service construction: `'...'` - * ({@link TOOL_ORDER_REST}) exactly once, no duplicate names. Returns the list + * Validate a configured tool-order list 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. */ @@ -141,7 +141,7 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine /** * Order collected tool schemas by the validated policy: with no configured * list, plain lexicographic name order; with one, listed names take their - * listed position and every unlisted tool lands at the `'...'` entry in + * listed position and every unlisted tool lands at the {@link TOOL_ORDER_REST} rest entry in * lexicographic name order. Never drops a tool, and both sorts are stable, so * tools sharing a name keep their collection order. */ @@ -176,8 +176,8 @@ export interface Config { * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed * tools take their listed position, names with no registered tool are * ignored, and tools absent from the list are inserted at the - * {@link TOOL_ORDER_REST} (`'...'`) entry in lexicographic name order. A - * configured list must contain `'...'` exactly once and no duplicate names — + * {@link TOOL_ORDER_REST} (`''`) entry in lexicographic name order. A + * configured list must contain the rest entry exactly once and no duplicate names — * anything else throws at load; a bad order config must never reach a * model request. When omitted, tools are ordered lexicographically by name. * Applied to the tools {@link SystemPrompt.assemble} collects, BEFORE the @@ -264,8 +264,8 @@ export class SystemPrompt extends Service { 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 '...' - // entry). Forcing the default to undefined keeps the key out of the + // 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[]), diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 0293f03e04..4131a8c7a0 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -18,8 +18,8 @@ function names(assembly: PromptAssembly): string[] { } describe('SystemPrompt tool order', () => { - it('exports the rest entry as "..."', () => { - expect(TOOL_ORDER_REST).toBe('...') + it('exports the rest entry as ""', () => { + expect(TOOL_ORDER_REST).toBe('') }) it('assembles tools in lexicographic name order when no toolOrder is configured', async () => { @@ -40,7 +40,7 @@ describe('SystemPrompt tool order', () => { expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu']) }) - it('applies a configured toolOrder: listed positions, rest at "..." lexicographically, absent names ignored', async () => { + it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically, absent names ignored', async () => { const ctx = await mount({ toolOrder: ['todo_write', 'ghost', 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']) @@ -73,8 +73,8 @@ describe('SystemPrompt tool order', () => { 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 "..." rest entry') + ])('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([ diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 8e2026a00a..1fc6af6b62 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -24,7 +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 `'...'` rest entry; absent — lexicographic), routed to `dsh-system-prompt` | +| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic), 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`). diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 71f8fe0171..e8b4f3723b 100644 --- a/packages/ui/acp-agent/package.json +++ b/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" diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 4438364bae..7e1de1226f 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/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' /** @@ -55,7 +56,7 @@ describe('dsh-acp-agent composition', () => { it('forwards toolOrder through agent-core to the system-prompt assembly', async () => { const ctx = await mount({ model: 'mock', - toolOrder: ['zulu', '...'], + 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 diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index f5f4dc66b5..ee68037414 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -25,7 +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 `'...'` rest entry; absent — lexicographic), routed to `dsh-system-prompt` | +| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic), 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) | diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index 0dffe49211..b8cf84cac4 100644 --- a/packages/ui/stdio-agent/package.json +++ b/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", diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index c06ddeee02..34ba201040 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/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' /** @@ -77,7 +78,7 @@ describe('dsh-stdio-agent app', () => { it('forwards toolOrder through agent-core to the system-prompt assembly', async () => { const ctx = await mount({ model: 'mock', - toolOrder: ['zulu', '...'], + 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97bcc8b288..8f6c140ee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) @@ -2360,6 +2366,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==} @@ -5090,6 +5099,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 +5195,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: @@ -5573,7 +5588,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: From adbba0deb2aaa3868ab2e91d2c1d72c3ccb1883e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:20:56 +0800 Subject: [PATCH 26/36] fix(system-prompt): reject a toolOrder that names an unregistered tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#196): a listed name with no registered tool was silently ignored; misconfiguration must block work instead. The check lives in the assembly — the earliest moment the registered tool set exists (tool plugins register after the service constructs) and the only universal one (cordis has no "all plugins loaded" event; registrations change at any time). assemble() is now async so the throw surfaces as a rejection rather than a synchronous escape from a Promise-returning method. Blast radius, pinned by a loop-level test: the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason, 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. A boot-time validation pass was considered and rejected (recorded in the RFC). The general principle — misconfiguration fails loud, never a silent skip — is added to AGENTS.md. --- AGENTS.md | 1 + docs/config-catalog.md | 20 ++++--- docs/cordis-catalog/services.md | 2 +- .../feature/2026-07-06-explicit-tool-order.md | 15 ++++-- .../core/agent-loop/tests/tool-order.spec.ts | 23 ++++++++ packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/src/index.ts | 53 +++++++++++++------ .../system-prompt/tests/tool-order.spec.ts | 19 ++++++- packages/ui/acp-agent/README.md | 2 +- packages/ui/stdio-agent/README.md | 2 +- 10 files changed, 106 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c582053122..34775024a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ([capability seams](docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)); 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` from `dsh-brand`), never bare `string` ([branded IDs](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)). - **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. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e0e7e6bff6..43db838b5d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -558,13 +558,17 @@ export interface Config { persona?: string /** * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed - * tools take their listed position, names with no registered tool are - * ignored, and tools absent from the list are inserted at the - * {@link TOOL_ORDER_REST} (`''`) entry in lexicographic name order. A - * configured list must contain the rest entry exactly once and no duplicate names — - * anything else throws at load; a bad order config must never reach a - * model request. When omitted, tools are ordered lexicographically by name. - * Applied to the tools {@link SystemPrompt.assemble} collects, BEFORE the + * tools take their listed position, and tools absent from the list are + * inserted at the {@link TOOL_ORDER_REST} (`''`) entry in + * lexicographic name order. A configured list must contain the rest entry + * exactly once, no duplicate names, and no name without a registered tool — + * a misconfigured order blocks work instead of silently reaching a model + * request: shape violations throw at load, and an unregistered name rejects + * every assembly (failing the turn 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 @@ -575,7 +579,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:161`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:174`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f02223bfa8..0034bbc259 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -190,7 +190,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:262`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:279`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index c4ae9f65fe..69838ef441 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -8,10 +8,15 @@ The order of the tool list a model call carries — `request/header.tools` on th ## Decision -The system-prompt assembly owns the canonical model-facing tool order, exactly where it already owns section order: +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: -- **One config key on `dsh-system-prompt`.** `toolOrder?: string[]` names tools in the exact order to send. A listed tool takes its listed position; a listed name with no registered tool is ignored (a deployment may list optional tools it does not always load); tools absent from the list are inserted at the `''` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among themselves. The list must contain the rest entry exactly once and no duplicate names — violations throw from the service constructor, failing the fiber at load, never mid-conversation. When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent) — determinism requires no configuration. -- **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 service surface and no loop change. +- 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 `''` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among the other unlisted tools. +- 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). @@ -26,6 +31,7 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - **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 @@ -34,7 +40,8 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - 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). ## Testing -Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/ignored/rest placement, 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, and that the frozen loop-built envelope survives to the adapter. 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}}`. +Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, 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}}`. diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 97cf78e48f..35329ce679 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -91,4 +91,27 @@ describe('loop-level canonical tool order', () => { 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) + }) }) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 1690b09ba6..0821fd6c55 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -7,7 +7,7 @@ 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 `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, names with no registered tool are ignored, 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. A list without exactly one rest entry, or with duplicates, throws at load. 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). | +| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` 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()` — 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`) @@ -16,7 +16,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- - `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.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` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed. ### Events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index cce43ab7e4..ea32d2b460 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -120,10 +120,13 @@ const GROUP_AT = /^\{\{([^{}]*)\}\}/ export const TOOL_ORDER_REST = '' /** - * Validate a configured tool-order list 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. + * 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 @@ -141,12 +144,22 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine /** * Order collected tool schemas by the validated policy: with no configured * list, plain lexicographic name order; with one, listed names take their - * listed position and every unlisted tool lands at the {@link TOOL_ORDER_REST} rest entry in - * lexicographic name order. Never drops a tool, and both sorts are stable, so - * tools sharing a name keep their collection order. + * 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[] { 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 => @@ -174,13 +187,17 @@ export interface Config { persona?: string /** * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed - * tools take their listed position, names with no registered tool are - * ignored, and tools absent from the list are inserted at the - * {@link TOOL_ORDER_REST} (`''`) entry in lexicographic name order. A - * configured list must contain the rest entry exactly once and no duplicate names — - * anything else throws at load; a bad order config must never reach a - * model request. When omitted, tools are ordered lexicographically by name. - * Applied to the tools {@link SystemPrompt.assemble} collects, BEFORE the + * tools take their listed position, and tools absent from the list are + * inserted at the {@link TOOL_ORDER_REST} (`''`) entry in + * lexicographic name order. A configured list must contain the rest entry + * exactly once, no duplicate names, and no name without a registered tool — + * a misconfigured order blocks work instead of silently reaching a model + * request: shape violations throw at load, and an unregistered name rejects + * every assembly (failing the turn 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 @@ -394,7 +411,8 @@ export class SystemPrompt extends Service { * 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), and every + * 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` @@ -408,7 +426,10 @@ export class SystemPrompt extends Service { * see {@link AssembleContext}). * @returns the assembly after the waterfall has run. */ - assemble(context: AssembleContext = {}): Promise { + // 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 { const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 4131a8c7a0..3366d1229d 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -18,6 +18,8 @@ function names(assembly: PromptAssembly): string[] { } 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 ""', () => { expect(TOOL_ORDER_REST).toBe('') }) @@ -40,12 +42,25 @@ describe('SystemPrompt tool order', () => { expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu']) }) - it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically, absent names ignored', async () => { - const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'bash'] }) + 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('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')]) diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 1fc6af6b62..36e794f92c 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -24,7 +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 `''` rest entry; absent — lexicographic), routed to `dsh-system-prompt` | +| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` 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`). diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index ee68037414..5fb0bbe78c 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -25,7 +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 `''` rest entry; absent — lexicographic), routed to `dsh-system-prompt` | +| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` 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) | From 709bb5912d99017004cdf4bc18702a4562fa551e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:48:41 +0800 Subject: [PATCH 27/36] fix: cordis-catalog --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0034bbc259..1fe0987ddc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -187,7 +187,7 @@ 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 +async assemble(context: AssembleContext = {}): Promise ``` Source: [`packages/core/system-prompt/src/index.ts:279`](../../packages/core/system-prompt/src/index.ts) From d1b52a063b75b89b655d14d5312306d8744948fc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:06:14 +0800 Subject: [PATCH 28/36] fix review findings: own-property and plain-JSON discipline in the schema subset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex findings on json-schema.ts, one discipline: - required-declared and every value check now use Object.hasOwn — 'in' let inherited names (toString) satisfy required, dodge additionalProperties: false, and validate a declared property against the value's prototype member instead of a carried one - isObjectLike now means PLAIN JSON object (proto chain of at most one link, realm-agnostic): a Date annotation or a Map-as-properties no longer passes structurally and serializes lossily — they fail loud as subset violations - startInProcessRun asserts BEFORE the defensive structuredClone, so a hostile schema fails as OutputSchemaError, never a raw DataCloneError Also the type-equiv catalog gap: tools.md gains the structured-output subset vocabulary (4 blocks) with matching manifest entries. The driver index also drops the runtime internals from its public re-export (runs acquire it internally; no external consumer remains — see the following commit). --- docs/core-data-structures/tools.md | 34 ++ packages/core/tools/src/json-schema.ts | 35 +- packages/core/tools/tests/json-schema.spec.ts | 50 ++ .../subagent/subagent-inprocess/src/index.ts | 22 +- scripts/type-equiv.manifest.json | 449 +++++++++++++++--- 5 files changed, 495 insertions(+), 95 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index a05ffb3966..a38a5b9c15 100644 --- a/docs/core-data-structures/tools.md +++ b/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 + 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: diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 35eeb240a4..bc0da537e1 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -91,9 +91,20 @@ const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'example const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] -/** Whether a value is a non-null, non-array object (structural, realm-agnostic). */ +/** + * 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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) + 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. */ @@ -116,6 +127,9 @@ function isJsonData(value: unknown, seen: Set): boolean { 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) @@ -194,8 +208,11 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen violations.push(`${path}.required must be an array of strings`) } else { const declared = isObjectLike(properties) ? properties : {} - for (const key of required) { - if (!(key in declared)) violations.push(`${path}.required names "${key}" which is not in 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`) } } } @@ -256,16 +273,20 @@ function checkValue(node: StructuredSchemaNode, value: unknown, path: string): s 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 (value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) + if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) } for (const [key, child] of Object.entries(properties)) { - if (value[key] === undefined) continue + 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 (!(key in properties)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) + if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) } } return violations diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index e7635b06f3..6fa895288e 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -154,6 +154,30 @@ describe('assertSupportedOutputSchema', () => { 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', () => { @@ -223,6 +247,32 @@ describe('validateStructuredValue', () => { 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"', diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 72906a78fc..08d240d248 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -25,11 +25,12 @@ import { 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 { - acquireStructuredRuntime, STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_INSTRUCTION, - type StructuredAcquisition, } from './structured.ts' declare module '@deepseek-ai/dsh-agent' { @@ -110,15 +111,18 @@ export function startInProcessRun( if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } - // Snapshot, then assert, the schema subset BEFORE any child exists (the + // 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). The snapshot is load-bearing: the caller keeps its - // reference, so validating and attaching the ORIGINAL would let a - // post-start() mutation drift the enforced schema away from the asserted - // one — the clone pins assertion, the model-visible parameters, and - // validateStructuredValue to the same isolation-immutable value. + // 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) - if (schema !== undefined) assertSupportedOutputSchema(schema) const childId = AgentId(randomUUID()) // The child's OWN events begin after the seed (fork seeds the parent's diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b5c648527e..e414cff1f4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,84 +1,375 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, - - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, - - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, - { "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/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" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, - - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, - - { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, - - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, - - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" } + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Branded", + "source": "packages/util/brand/src/index.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Message", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "MessageSourceMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "FinishReasonMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "GenerateOptions", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ToolSchema", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmCallConfig", + "source": "packages/llm/llm/src/call-config.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Agent", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "HookContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "PromptDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionStartSource", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "StreamChunk", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "TokenUsage", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "AppIdentity", + "source": "packages/llm/llm/src/attribution.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "EpochHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TodoItem", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnTriggerMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnEndReasonMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceEventType", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceOp", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceIntent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceNode", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "CreateSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolDefinition", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaProp", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "InferArgs", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "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/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" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashRunResult", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "CollectedOutput", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTask", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTaskRead", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTarget", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTargetKey", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsVersion", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsDirEntry", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteIntent", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditRequest", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsErrorCode", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsPolicyExec", + "source": "packages/fs/fs-policy/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FileReadOutcome", + "source": "packages/fs/tool-fs/src/read-render.ts" + }, + { + "doc": "docs/core-data-structures/compaction.md", + "symbol": "CompactionResult", + "source": "packages/compact/compact/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentCapabilities", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentResult", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStopReasonMap", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentRun", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProvider", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchSource", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchBody", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebProviderStatus", + "source": "packages/web/web/src/types.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" + } ] } From 280233ba781034fcdd662377385676609e3c2cc9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:08:12 +0800 Subject: [PATCH 29/36] fix review finding: the capture commits only on the final post-execute accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-seam blocker: structured_output recorded its value in the tool BODY, before tools/post-execute could block the call — a PostToolUse hook's block turned the logged result into isError while readResult still returned structured success and the continuation veto ended the turn. Two-phase commit: the body validates and STAGES (RunState.pending); a fourth runtime listener on tools/post-execute — prepend, so await next() returns the composed final decision — promotes the stage to captured only on an accepted call, and clears it on every path. A block now yields a consistent pair: the model and log see the isError feedback, the run settles error with no structured value, and the turn continues so the model can react. Regressions: block denies the capture end-to-end; accept-with-replacement still commits. --- .../subagent-inprocess/src/structured.ts | 64 +++++- .../tests/structured.spec.ts | 186 ++++++++++++------ 2 files changed, 179 insertions(+), 71 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 370f7a538d..a557e44785 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -36,14 +36,20 @@ * 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. + * 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 with two kinds of holder: each backend acquires for - * its plugin lifetime (so the tool exists before any run), and each structured - * RUN acquires from start to settle (so a backend hot-reload mid-run cannot - * unregister the capture tool out from under a live child). Registrations are - * effects on the ROOT context — their natural upper bound is app teardown — and - * the refcount disposes them when the last holder releases. + * 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 */ @@ -53,7 +59,7 @@ 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 { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' +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. */ @@ -75,6 +81,15 @@ export const STRUCTURED_OUTPUT_INSTRUCTION /** 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 } } @@ -106,7 +121,7 @@ export interface StructuredAcquisition { /** * Acquire the per-root-context structured runtime, registering the capture tool - * and the two waterfall listeners on the FIRST acquisition. See the module doc + * 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). @@ -179,7 +194,9 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void { // 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) - state.captured = { value: args } + // 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.' }]) }, }) @@ -243,6 +260,33 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void { 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, + ): Promise { + 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 diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index ba693cfecd..0de036a0a0 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -11,8 +11,7 @@ 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 * as spawn from '@deepseek-ai/dsh-subagent-spawn' -import * as fork from '@deepseek-ai/dsh-subagent-fork' +import { startInProcessRun } from '../src/index.ts' import { acquireStructuredRuntime, STRUCTURED_OUTPUT_INSTRUCTION, @@ -28,11 +27,14 @@ const SCHEMA: StructuredOutputSchema = { } /** - * Real loop + scripted mock model + the REAL spawn backend (which acquires the - * structured runtime at apply, exactly as shipped). The mock model script - * drives the child's structured_output calls. + * 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, options?: { withFork?: boolean }) { +async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) await ctx.plugin(LlmService) @@ -43,13 +45,15 @@ async function setup(script: Script, options?: { withFork?: boolean }) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const forkFiber = options?.withFork - ? await ctx.plugin(fork, { providerName: 'fork' }) - : undefined + 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, fiber, forkFiber } + return { ctx, parent, adapter, disposeProvider } } function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial): SubagentStartRequest { @@ -263,6 +267,62 @@ describe('in-process structured output', () => { 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 @@ -298,6 +358,21 @@ describe('in-process structured output', () => { }) 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. @@ -396,10 +471,13 @@ describe('in-process structured output', () => { // 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) - const acquisition = acquireStructuredRuntime(ctx) acquisition.attach(parent, SCHEMA) const shaped = await ctx.systemPrompt.assemble({ agent: parent }) expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL) @@ -412,57 +490,37 @@ describe('in-process structured output', () => { }) }) - describe('runtime lifetime (refcount: backends + live runs)', () => { - it('registers the capture tool while a backend is loaded and unregisters when the last unloads', async () => { - const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() - await fiber.dispose() - // fork still holds a reference. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() - await forkFiber!.dispose() - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - }) - - it('a live run-level acquisition keeps the runtime registered after EVERY backend unloads', async () => { - // Simulates the run-holder half of the two-level lifetime: a structured - // run acquires at start and releases at settle, so registration ordering - // is settle-then-unregister even if all backends unload first. (A real - // in-process child dies WITH its backend's fiber — the acquisition's - // observable job is this ordering, which a manual holder pins directly.) - const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) - const runHolder = acquireStructuredRuntime(ctx) - await fiber.dispose() - await forkFiber!.dispose() - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() - runHolder.release() - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - }) - - it('a structured run releases its acquisition when it settles (backend unload mid-run)', async () => { - const { ctx, parent, fiber } = await setup(['hang']) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) - // 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 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') - // Both holders (backend + run) released — nothing keeps the runtime now. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - await run.dispose() - }) - - it('fork children capture structured output through the same runtime', async () => { + 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: 9 }), - ], { withFork: true }) - const run = ctx.subagents.start('fork', structuredRequest(parent)) + 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 - expect(result.structured).toEqual({ answer: 9 }) + // 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) @@ -513,13 +571,16 @@ describe('in-process structured output', () => { acquisition.detach(parent) acquisition.detach(parent) acquisition.release() - // The backend still holds its own reference from setup(). - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + // 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, @@ -527,16 +588,19 @@ describe('in-process structured output', () => { agent: parent, }) expect(result.isError).toBe(true) - expect(result.content[0]).toMatchObject({ type: 'text' }) + 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() }) }) From 0e0f3b2f19f9768b2518fca636f882c13bbc60c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:09:02 +0800 Subject: [PATCH 30/36] review: acquire the structured runtime per run, not per backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex simplification concern plus the duplication comment on the spawn apply, resolved by deletion: the backend-lifetime holds are gone, so the runtime registers at the first structured run and disposes when the last settles — a deployment that never passes outputSchema carries no always-on global state, and there is no per-backend acquisition block left to extract. The driver spec now drives an INLINE spawn-shaped provider over startInProcessRun, which removes the spawn/fork devDependencies (the test-only workspace cycle); plugin-level structured coverage moves to the backends' own specs (capture through the shipped plugin, mid-run backend unload, seeded fork capture). tools.md, the driver README, and both backend READMEs describe the run-scoped lifetime; the module-graph regenerates without the cycle edges. --- packages/subagent/subagent-fork/src/index.ts | 15 ++--- .../subagent-fork/tests/subagent-fork.spec.ts | 29 +++++++--- .../subagent/subagent-inprocess/README.md | 14 +++-- .../subagent/subagent-inprocess/package.json | 2 - packages/subagent/subagent-spawn/README.md | 2 +- packages/subagent/subagent-spawn/src/index.ts | 20 ++----- .../tests/subagent-spawn.spec.ts | 58 ++++++++++++++++--- pnpm-lock.yaml | 6 -- 8 files changed, 91 insertions(+), 55 deletions(-) diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 4ee28001d2..8f91186cf0 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -25,13 +25,13 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' // `tools` is deliberately NOT injected — same rationale as subagent-spawn: the -// 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. +// 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. */ @@ -84,12 +84,5 @@ class ForkProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - // Hold the structured runtime for the plugin's lifetime (see the spawn - // backend — same two-level lifetime: backends for availability, runs for - // mid-run survival across a backend unload). - ctx.effect(() => { - const acquisition = acquireStructuredRuntime(ctx) - return () => { acquisition.release() } - }, 'subagent-fork structured runtime') ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 74974942b5..90e2d583d8 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/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[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, @@ -170,12 +191,6 @@ describe('dsh-subagent-fork', () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - // The backend does NOT inject 'tools' (the structured runtime gates its - // capture-tool registration on tools availability itself, keeping backend - // apply timing — and the delegation tool's prompt position — unchanged); - // the registries are loaded here so the runtime registers eagerly anyway. - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) const fiber = await ctx.plugin(fork, { providerName: 'fork' }) expect(ctx.subagents.list()).toEqual(['fork']) await fiber.dispose() diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index a4fcc51fbc..f6870929a8 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,7 +8,7 @@ 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); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists; +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). @@ -19,16 +19,18 @@ 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: `acquireStructuredRuntime(ctx): StructuredAcquisition` +### Structured output (package-internal runtime) -The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder: +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: -- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire 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 appended to its `system` text (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request. +- 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 records the value. +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 with two kinds of holder: each backend acquires for its plugin lifetime (`apply`), and each structured RUN holds its own acquisition from start to settle — so unregistration can never precede a live run's settle, and the runtime disposes only when the last backend AND the last run are gone. `release()` is idempotent per acquisition. +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` diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index ecc177162f..4e6b72533a 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -37,8 +37,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 696007a693..b976ef5a63 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -10,7 +10,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## Capabilities -`{ 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 shared [structured runtime](../subagent-inprocess/README.md) (the backend acquires it for its plugin lifetime; each structured run holds its own acquisition until it settles). Tool-scoping is deferred (the service rejects a request needing it 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 diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 7da954bc44..2d8f118b4e 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -22,14 +22,14 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' -// `tools` is deliberately NOT injected: the structured runtime gates its own -// capture-tool registration on `tools` availability internally, 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. +// `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. */ @@ -64,13 +64,5 @@ class SpawnProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - // Hold the structured runtime for the plugin's lifetime, so the capture tool - // and its request-shaping listeners are registered before the first - // structured run and torn down when the last backend unloads (live runs hold - // their own acquisitions, so an unload mid-run cannot strand a child). - ctx.effect(() => { - const acquisition = acquireStructuredRuntime(ctx) - return () => { acquisition.release() } - }, 'subagent-spawn structured runtime') ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 63d26c0531..1dad9748e9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/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[0] @@ -251,18 +251,60 @@ describe('dsh-subagent-spawn', () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - // The backend does NOT inject 'tools' (the structured runtime gates its - // capture-tool registration on tools availability itself, keeping backend - // apply timing — and the delegation tool's prompt position — unchanged); - // the registries are loaded here so the runtime registers eagerly anyway. - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) expect(ctx.subagents.list()).toEqual(['spawn']) await fiber.dispose() 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') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35d940028b..d5fb68c746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,12 +655,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt From 23fb1febcd585b2cf32bf0eff28244dc73229644 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:17:52 +0800 Subject: [PATCH 31/36] chore: keep the type-equiv manifest in its one-line-per-entry format The previous commit rewrote the whole file through a JSON pretty-printer, reformatting every existing entry; restore the established compact style with the four new entries appended to the tools.md group. --- scripts/type-equiv.manifest.json | 453 ++++++------------------------- 1 file changed, 83 insertions(+), 370 deletions(-) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e414cff1f4..b66882d8c9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,375 +1,88 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { - "doc": "docs/core-data-structures/core.md", - "symbol": "Branded", - "source": "packages/util/brand/src/index.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "Message", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "MessageSourceMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "FinishReasonMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "GenerateOptions", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "ToolSchema", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "LlmCallConfig", - "source": "packages/llm/llm/src/call-config.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "Agent", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "HookContext", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "PromptDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "ContinuationDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SessionStartSource", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.md", - "symbol": "StreamChunk", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.md", - "symbol": "TokenUsage", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.md", - "symbol": "AppIdentity", - "source": "packages/llm/llm/src/attribution.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SessionEventMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "EpochHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "TodoItem", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "TurnTriggerMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "TurnEndReasonMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SurfaceEventType", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SurfaceOp", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SurfaceIntent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "SurfaceNode", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/persistence.md", - "symbol": "SessionHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/persistence.md", - "symbol": "CreateSessionOptions", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/tools.md", - "symbol": "ToolDefinition", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.md", - "symbol": "SchemaProp", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.md", - "symbol": "SchemaSpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.md", - "symbol": "InferArgs", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.md", - "symbol": "ToolExecution", - "source": "packages/core/tools/src/index.ts" - }, - { - "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/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" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "BashRunResult", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "BashTask", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "BashTaskRead", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsTarget", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsTargetKey", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsVersion", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsInfo", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsDirEntry", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsWriteIntent", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsWriteOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsEditRequest", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsEditOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsErrorCode", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FsPolicyExec", - "source": "packages/fs/fs-policy/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.md", - "symbol": "FileReadOutcome", - "source": "packages/fs/tool-fs/src/read-render.ts" - }, - { - "doc": "docs/core-data-structures/compaction.md", - "symbol": "CompactionResult", - "source": "packages/compact/compact/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentCapabilities", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentResult", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentStopReasonMap", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentRun", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentProvider", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebSearchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebSearchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebSearchSource", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebFetchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebFetchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebFetchBody", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.md", - "symbol": "WebProviderStatus", - "source": "packages/web/web/src/types.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/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, + + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, + + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "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" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, + + { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, + + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, + + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" } ] } From b149a040d0a94bca2e294a97c8b22526d52881ee Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:39:48 +0800 Subject: [PATCH 32/36] docs: fix catalog and budgets --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- scripts/doc-budgets.manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dfa1b9a458..9f08ea0ab1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -597,7 +597,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:174`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:175`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c43c14aed7..cfe353831b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -190,7 +190,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:279`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:284`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 353a3acd28..94afc8dc73 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1660, + "AGENTS.md": 1690, "docs/AGENTS.md": 1315, "docs/architecture.md": 1630, "docs/cordis-primer.md": 550, From 161275e287370fd430fc74174bba0149c7fc1e94 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:03:12 +0800 Subject: [PATCH 33/36] feat: add assembly-time validation rejects placeholder --- docs/config-catalog.md | 13 ++++++---- .../feature/2026-07-06-explicit-tool-order.md | 4 +++- packages/core/system-prompt/README.md | 6 ++--- packages/core/system-prompt/src/index.ts | 24 +++++++++++++------ .../system-prompt/tests/tool-order.spec.ts | 10 ++++++++ 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f08ea0ab1..31cff65d9b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -582,10 +582,13 @@ export interface Config { * 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 (failing the turn 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 + * 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 @@ -597,7 +600,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:175`](../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` diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 69838ef441..3604e70972 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -13,6 +13,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w - 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 `''` 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. @@ -41,7 +42,8 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - 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, 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}}`. +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}}`. diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 0821fd6c55..704cd8e80f 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -7,16 +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 `''` 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()` — 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). | +| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` 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` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` 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 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 3daf293f09..81ca8087bb 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -114,8 +114,8 @@ 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). - * Deliberately not a valid model-facing tool name, so it can never collide - * with a real tool. + * 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 = '' @@ -154,6 +154,10 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine * 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)) @@ -194,10 +198,13 @@ export interface Config { * 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 (failing the turn 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 + * 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 @@ -356,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. */ diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 3366d1229d..02cc99b2d7 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -61,6 +61,16 @@ describe('SystemPrompt tool order', () => { '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')]) From aeaccf6d360e9475b54adfd61096a9cef1e0ab25 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:13:01 +0800 Subject: [PATCH 34/36] docs: satisfy the new export-JSDoc gate on the assertion signature Master's verify-export-jsdoc (landed mid-stack) wants @returns on every exported function including asserts-returning ones; document the narrowing. --- packages/core/tools/src/json-schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index bc0da537e1..4c1036773b 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -256,6 +256,8 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen * (`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[] = [] From 958742cac675952fd250b7a471a6ebc86353e17a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:33:09 +0800 Subject: [PATCH 35/36] fix: cordis-catalog --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cfe353831b..231d49f2a3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -190,7 +190,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:284`](../../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` From 020595529486c79e1cced060eb9adfe95f5e086f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:21:10 +0800 Subject: [PATCH 36/36] docs: update budget --- scripts/doc-budgets.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 94afc8dc73..b23811e3d0 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1690, + "AGENTS.md": 1691, "docs/AGENTS.md": 1315, "docs/architecture.md": 1630, "docs/cordis-primer.md": 550,