mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix: add tools reorder to system prompt
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
94
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
90
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user