Procházet zdrojové kódy

fix: add tools reorder to system prompt

imccyu před 2 měsíci
rodič
revize
42e7a2f691

+ 39 - 14
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`
 

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

@@ -189,7 +189,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
 assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 ```
 
-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`
 

+ 1 - 1
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.
 

+ 1 - 0
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
 

+ 40 - 0
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.

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

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

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

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

+ 17 - 0
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')

+ 94 - 0
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<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+function registerNamed(ctx: Context, name: string) {
+  ctx.tools.register(defineTool({
+    name,
+    description: `the ${name} tool`,
+    parameters: {},
+    async execute() {
+      return [{ type: 'text', text: name }]
+    },
+  }))
+}
+
+/** Run one text-only turn and return the harness context + agent. */
+async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
+  const adapter = new MockAdapter([textResponse('done')])
+  const ctx = await harness(adapter, toolOrder)
+  for (const name of registrationOrder) registerNamed(ctx, name)
+  const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+  agent.send([{ type: 'text', text: 'go' }])
+  await waitForIdle(ctx, agent)
+  return { ctx, agent, adapter }
+}
+
+describe('loop-level canonical tool order', () => {
+  it('logs the request/header with tools in canonical order, not registration order', async () => {
+    const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    // The dispatched request is built FROM the logged header (whose tools the
+    // assembly already canonicalized) and reaches the adapter deep-frozen —
+    // the marker the reconstruction invariant keys on.
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+    expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
+  })
+
+  it('produces the same header order for any registration order', async () => {
+    const first = await runTurn(['alpha', 'mike', 'zulu'])
+    const second = await runTurn(['zulu', 'mike', 'alpha'])
+    const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
+    expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(names(second)).toEqual(names(first))
+  })
+
+  it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
+    const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+  })
+})

+ 1 - 0
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`)
 

+ 91 - 11
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<string>()
+  for (const name of toolOrder) {
+    if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
+    seen.add(name)
+  }
+  if (!seen.has(TOOL_ORDER_REST)) {
+    throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
+  }
+  return toolOrder
+}
+
+/**
+ * Order collected tool schemas by the validated policy: with no configured
+ * list, plain lexicographic name order; with one, listed names take their
+ * listed position and every unlisted tool lands at the `'...'` 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<string, string
 export class SystemPrompt extends Service {
   static Config: z<Config> = z.object({
     persona: z.string().default(''),
+    // A schemastery array defaults to [] when omitted, but an omitted
+    // toolOrder must stay absent ("lexicographic order"), not become an
+    // explicitly-configured empty list (which is invalid — it lacks the '...'
+    // entry). Forcing the default to undefined keeps the key out of the
+    // validated config; the cast is needed because .default() expects the
+    // array type.
+    toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   })
 
   private sections: PromptSection[] = []
   private toolProviders: (() => ToolSchema[])[] = []
   private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
+  private readonly toolOrder: string[] | undefined
 
   constructor(ctx: Context, public config: Config) {
     super(ctx, 'systemPrompt')
+    this.toolOrder = validateToolOrder(config.toolOrder)
     // The harness-owned openers. They live HERE (not on the loop plugin) so a
     // deployment that swaps in a different loop keeps them: the identity is a
     // harness fact stated ahead of everything, and the persona is the
@@ -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))

+ 90 - 0
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<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt, config)
+  return ctx
+}
+
+function names(assembly: PromptAssembly): string[] {
+  return assembly.tools.map(t => t.name)
+}
+
+describe('SystemPrompt tool order', () => {
+  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')
+  })
+})

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

@@ -24,6 +24,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
 |---|---|---|
 | `model` | (required) | the per-session agent template the bridge creates agents from |
 | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
+| `toolOrder` | — | explicit model-facing tool order (a name list with one `'...'` 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`).

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

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

+ 21 - 0
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

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

@@ -25,6 +25,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 |---|---|---|
 | `model` | (required) | the pre-created `main` agent's model |
 | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
+| `toolOrder` | — | explicit model-facing tool order (a name list with one `'...'` 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) |

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

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

+ 21 - 0
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