Przeglądaj źródła

Merge branch 'master' into worktree-node-22-18-compat

imccyu 2 miesięcy temu
rodzic
commit
85308526c8

+ 1 - 0
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; don't split preemptively.
 - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template).
 - **No hardcoded tunables in plugins**: anything two deployments could want different — timeouts, caps, model names, base URLs — is a defaulted, validated `Config` field, not a literal; a `DEFAULT_*` constant or test-only seam is not configurability. The test: changeable from `cordis.yml`, no code edit. Protocol/wire constants, external-spec values, security invariants stay hardcoded.
+- **Misconfiguration fails loud**: a config value referencing something that does not exist (a `toolOrder` tool name, a plugin path) throws — at load when the check is self-contained, else at the earliest moment the referent exists (for `toolOrder`, every prompt assembly) — never a silent skip.
 - **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
 - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
 - **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.

+ 43 - 11
docs/config-catalog.md

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

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

@@ -187,10 +187,10 @@ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections,
 section(section: PromptSection): () => void
 tools(provider: () => ToolSchema[]): () => void
 variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
-assemble(context: AssembleContext = {}): Promise<PromptAssembly>
+async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:203`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts)
 
 ## `ctx.tools` — `ToolRegistry`
 

+ 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

@@ -60,6 +60,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
 | [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
 | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
+| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
 
 ### Simplification
 

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

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

Plik diff jest za duży
+ 0 - 0
examples/acp-agent/tests/snapshots/text-turn/session.jsonl


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

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

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

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

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

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

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

@@ -0,0 +1,117 @@
+/**
+ * Loop-level tool-order determinism: the request/header event — and therefore
+ * the frozen request the adapter receives — carries the assembly's canonical
+ * tool order (system-prompt's `toolOrder` config, or lexicographic name
+ * order), regardless of the order tool plugins happened to register in.
+ * Registration order is a plugin-load artifact (concurrent dynamic imports
+ * race), so nothing downstream of the registry may depend on it.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
+import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
+import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import { MockAdapter, textResponse } from './mock-adapter.ts'
+
+async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  ctx.llm.registerAdapter(['mock'], adapter)
+  return ctx
+}
+
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+function registerNamed(ctx: Context, name: string) {
+  ctx.tools.register(defineTool({
+    name,
+    description: `the ${name} tool`,
+    parameters: {},
+    async execute() {
+      return [{ type: 'text', text: name }]
+    },
+  }))
+}
+
+/** Run one text-only turn and return the harness context + agent. */
+async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
+  const adapter = new MockAdapter([textResponse('done')])
+  const ctx = await harness(adapter, toolOrder)
+  for (const name of registrationOrder) registerNamed(ctx, name)
+  const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+  agent.send([{ type: 'text', text: 'go' }])
+  await waitForIdle(ctx, agent)
+  return { ctx, agent, adapter }
+}
+
+describe('loop-level canonical tool order', () => {
+  it('logs the request/header with tools in canonical order, not registration order', async () => {
+    const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    // The dispatched request is built FROM the logged header (whose tools the
+    // assembly already canonicalized) and reaches the adapter deep-frozen —
+    // the marker the reconstruction invariant keys on.
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+    expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
+  })
+
+  it('produces the same header order for any registration order', async () => {
+    const first = await runTurn(['alpha', 'mike', 'zulu'])
+    const second = await runTurn(['zulu', 'mike', 'alpha'])
+    const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
+    expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
+    expect(names(second)).toEqual(names(first))
+  })
+
+  it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
+    const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
+    const header = foldRequestHeader(agent.session.events)
+    expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
+    expect(Object.isFrozen(adapter.requests[0])).toBe(true)
+  })
+
+  it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
+    // The assemble rejection escapes to runTurn's outer catch: the open turn
+    // closes with an `error` reason (agent/error mirrors it), no step opens,
+    // no request/header is logged, the adapter never sees a request, and the
+    // agent returns to idle — a misconfigured deployment fails every turn
+    // deterministically instead of silently reordering nothing.
+    const adapter = new MockAdapter([textResponse('never sent')])
+    const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
+    registerNamed(ctx, 'alpha')
+    const errors: Error[] = []
+    ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(0)
+    expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
+    expect(foldRequestHeader(agent.session.events)).toBeUndefined()
+    const end = agent.session.events.find(e => e.type === 'turn/end')
+    expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
+    // The turn is balanced (turn/start → turn/end) with no step events inside.
+    expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
+  })
+})

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

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

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

@@ -88,7 +88,8 @@ export interface AssembledSection {
  *
  * Tool schemas are part of the assembly by design: "what the model is told it
  * can do" is one coherent thing managed here, even though adapters transmit
- * `tools` as a separate wire field rather than prompt text.
+ * `tools` as a separate wire field rather than prompt text. They arrive in
+ * the canonical model-facing order (see {@link Config.toolOrder}).
  *
  * `variables` carries every registered prompt variable resolved against this
  * assembly's context — key present means registered, `undefined` value means
@@ -110,6 +111,70 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
 /** A complete `{{...}}` reference group at the scan position (validated after). */
 const GROUP_AT = /^\{\{([^{}]*)\}\}/
 
+/**
+ * The rest entry for {@link Config.toolOrder}: the position where registered
+ * tools not named in the list are inserted (in lexicographic name order).
+ * Reserved: collected tool schemas using this name are rejected before
+ * ordering, so the marker can never collide with a real model-facing tool.
+ */
+export const TOOL_ORDER_REST = '<unlisted-tools>'
+
+/**
+ * Validate a configured tool-order list's shape at service construction:
+ * the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names.
+ * Returns the list (or undefined when unconfigured); throws otherwise,
+ * failing the service at load — a bad order config must never reach an
+ * assembly. Whether every listed name matches a registered tool is checked
+ * at each assembly instead ({@link orderTools}): tool plugins register after
+ * this service constructs, so the tool set does not exist yet here.
+ */
+function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
+  if (toolOrder === undefined) return undefined
+  const seen = new Set<string>()
+  for (const name of toolOrder) {
+    if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
+    seen.add(name)
+  }
+  if (!seen.has(TOOL_ORDER_REST)) {
+    throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
+  }
+  return toolOrder
+}
+
+/**
+ * Order collected tool schemas by the validated policy: with no configured
+ * list, plain lexicographic name order; with one, listed names take their
+ * listed position and every unlisted tool lands at the
+ * {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
+ * name with no collected tool throws — misconfiguration fails loud, and this
+ * is the earliest moment the registered tool set exists to check against
+ * (tool plugins register after the service constructs, so load time is too
+ * early): the assembly rejects, failing the caller's turn before any model
+ * request. Never drops a tool, and both sorts are stable, so tools sharing a
+ * name keep their collection order.
+ */
+function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
+  const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
+  if (reserved !== undefined) {
+    throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
+  }
+  if (toolOrder === undefined) return tools.sort(compareToolNames)
+  const registered = new Set(tools.map(tool => tool.name))
+  const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
+  if (unknown.length > 0) {
+    throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
+  }
+  const listed = new Set(toolOrder)
+  const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
+  return toolOrder.flatMap(name =>
+    name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
+}
+
+/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
+function compareToolNames(a: ToolSchema, b: ToolSchema): number {
+  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
+}
+
 /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
 export interface Config {
   /**
@@ -125,6 +190,29 @@ export interface Config {
    * deployment opens with the harness identity alone.
    */
   persona?: string
+  /**
+   * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
+   * tools take their listed position, and tools absent from the list are
+   * inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
+   * lexicographic name order. A configured list must contain the rest entry
+   * exactly once, no duplicate names, and no name without a registered tool —
+   * a misconfigured order blocks work instead of silently reaching a model
+   * request: shape violations throw at load, and an unregistered name rejects
+   * every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
+   * not be a collected tool name; such a provider output also rejects the
+   * assembly. The single assembly-time validation rejects either failure
+   * before any model request — the earliest moment the registered tool set
+   * exists to check against, since tool plugins register after this service
+   * constructs. When omitted, tools are ordered lexicographically by name.
+   * Applied to the tools
+   * {@link SystemPrompt.assemble} collects, BEFORE the
+   * `system-prompt/assemble` waterfall — like the sections' `order` sort, it
+   * canonicalizes what the registry contributed (registration order is a
+   * plugin-load artifact); a waterfall listener that mutates the tool list
+   * owns the determinism of what it emits. Rationale (and why not per-plugin
+   * weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
+   */
+  toolOrder?: string[]
 }
 
 /**
@@ -203,14 +291,23 @@ function interpolate(section: AssembledSection, variables: Record<string, string
 export class SystemPrompt extends Service {
   static Config: z<Config> = z.object({
     persona: z.string().default(''),
+    // A schemastery array defaults to [] when omitted, but an omitted
+    // toolOrder must stay absent ("lexicographic order"), not become an
+    // explicitly-configured empty list (which is invalid — it lacks the
+    // rest entry). Forcing the default to undefined keeps the key out of the
+    // validated config; the cast is needed because .default() expects the
+    // array type.
+    toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   })
 
   private sections: PromptSection[] = []
   private toolProviders: (() => ToolSchema[])[] = []
   private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
+  private readonly toolOrder: string[] | undefined
 
   constructor(ctx: Context, public config: Config) {
     super(ctx, 'systemPrompt')
+    this.toolOrder = validateToolOrder(config.toolOrder)
     // The harness-owned openers. They live HERE (not on the loop plugin) so a
     // deployment that swaps in a different loop keeps them: the identity is a
     // harness fact stated ahead of everything, and the persona is the
@@ -266,7 +363,10 @@ export class SystemPrompt extends Service {
   /**
    * Contribute a tool-schema provider that is evaluated at each assembly
    * call (so it can reflect the live registry state). The provider is
-   * removed when the calling fiber is disposed. Emits `system-prompt/change`.
+   * removed when the calling fiber is disposed. A provider must not return a
+   * schema named {@link TOOL_ORDER_REST}; that name is reserved for
+   * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
+   * `system-prompt/change`.
    * @param provider - evaluated at every {@link assemble} for fresh schemas.
    * @returns the disposer that removes the provider.
    */
@@ -323,19 +423,28 @@ export class SystemPrompt extends Service {
 
   /**
    * Assemble the current prompt for one caller: section texts are resolved
-   * against `context` and sorted by order, tools collected from all
-   * providers, and every registered variable resolved against `context` into
-   * `assembly.variables`. Tool schemas are deep-cloned because adapters and
-   * request waterfalls may mutate schema objects. Runs through the
-   * `system-prompt/assemble` waterfall, giving listeners the opportunity to
-   * mutate or replace the assembly before it reaches the model. Await the
-   * result before reading the assembly values — waterfall listeners may be
-   * async. Interpolation happens later, in {@link renderPrompt}.
+   * against `context` and sorted by order, tools collected from all providers
+   * and put in the canonical model-facing order ({@link Config.toolOrder}, or
+   * lexicographic name order when unconfigured — provider registration order
+   * is a plugin-load artifact and never reaches the assembly; a configured
+   * order naming a tool no provider contributed rejects the assembly), and every
+   * registered variable resolved against `context` into `assembly.variables`.
+   * Tool schemas are deep-cloned because adapters and request waterfalls may
+   * mutate schema objects. Runs through the `system-prompt/assemble`
+   * waterfall, giving listeners the opportunity to mutate or replace the
+   * assembly before it reaches the model — like the sections' `order` sort,
+   * tool canonicalization happens on the initial assembly, and a listener
+   * owns the determinism of whatever it emits. Await the result before
+   * reading the assembly values — waterfall listeners may be async.
+   * Interpolation happens later, in {@link renderPrompt}.
    * @param context - what this assembly is for (defaults to an empty context;
    *   see {@link AssembleContext}).
    * @returns the assembly after the waterfall has run.
    */
-  assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
+  // async so the misconfigured-toolOrder throw in orderTools surfaces as a
+  // rejection: a Promise-returning method must not throw synchronously
+  // (`assemble().catch(...)` would miss it).
+  async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
     const variables: Record<string, string | undefined> = {}
     for (const [name, provider] of this.variableProviders) {
       variables[name] = provider(context)
@@ -348,8 +457,10 @@ export class SystemPrompt extends Service {
           text: typeof section.text === 'function' ? section.text(context) : section.text,
         }))
         .sort((a, b) => a.order - b.order),
-      tools: this.toolProviders.flatMap(provider =>
-        provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
+      tools: orderTools(
+        this.toolProviders.flatMap(provider =>
+          provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
+        this.toolOrder),
       variables,
     }
     return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 19 - 2
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)
@@ -2363,6 +2369,9 @@ packages:
   '@types/tough-cookie@4.0.5':
     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
 
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
@@ -5100,6 +5109,9 @@ snapshots:
 
   '@types/tough-cookie@4.0.5': {}
 
+  '@types/trusted-types@2.0.7':
+    optional: true
+
   '@types/unist@3.0.3': {}
 
   '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
@@ -5193,7 +5205,10 @@ snapshots:
       '@typescript-eslint/types': 8.61.0
       eslint-visitor-keys: 5.0.1
 
-  '@upsetjs/venn.js@2.0.0': {}
+  '@upsetjs/venn.js@2.0.0':
+    optionalDependencies:
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
 
   '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
     dependencies:
@@ -5583,7 +5598,9 @@ snapshots:
 
   diff@9.0.0: {}
 
-  dompurify@3.4.11: {}
+  dompurify@3.4.11:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
 
   dts-resolver@3.0.0(oxc-resolver@11.20.0):
     optionalDependencies:

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

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików