From 671cbe173eec053ac04b205419c6d4bf388cc6f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 6 Jul 2026 18:40:57 +0800 Subject: [PATCH 01/11] 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/11] 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 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 03/11] 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 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 04/11] 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 589259e70c1ae1f57a421bbedc9c1d617df53b82 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 7 Jul 2026 19:17:16 +0800 Subject: [PATCH 05/11] 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 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 06/11] 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 07/11] 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 08/11] 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 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 09/11] 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 10/11] 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 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 11/11] 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`