Merge branch 'structured-output-subagent-seam' into worktree-dynamic-workflows
# Conflicts: # docs/rfc/INDEX.md # examples/acp-agent/tests/snapshots/text-turn/session.jsonl
This commit is contained in:
103
.agents/skills/dsh-pre-push-checks/SKILL.md
Normal file
103
.agents/skills/dsh-pre-push-checks/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
name: dsh-pre-push-checks
|
||||||
|
description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# DSH Pre-Push Checks
|
||||||
|
|
||||||
|
Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, demo smoke, and built-bin smoke.
|
||||||
|
|
||||||
|
## First Steps
|
||||||
|
|
||||||
|
1. Confirm the checkout and branch.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short --branch
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Inspect the outgoing diff.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --stat
|
||||||
|
git diff --name-only origin/$(git branch --show-current)...HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch.
|
||||||
|
|
||||||
|
3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence.
|
||||||
|
|
||||||
|
## Required Baseline
|
||||||
|
|
||||||
|
Run these before every non-trivial push:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run typecheck
|
||||||
|
pnpm run lint
|
||||||
|
pnpm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI.
|
||||||
|
|
||||||
|
## Add Gates By Touched Surface
|
||||||
|
|
||||||
|
Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, RFCs, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages.
|
||||||
|
|
||||||
|
Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`.
|
||||||
|
|
||||||
|
Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run test:snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior.
|
||||||
|
|
||||||
|
## Full Local CI Approximation
|
||||||
|
|
||||||
|
Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior.
|
||||||
|
|
||||||
|
## Handling Failures
|
||||||
|
|
||||||
|
If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||||
|
|
||||||
|
If a failure looks environment-specific, prove it:
|
||||||
|
|
||||||
|
- Record the exact command, failing test, and platform-specific mismatch.
|
||||||
|
- Confirm the relevant non-platform gates pass.
|
||||||
|
- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate.
|
||||||
|
- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI.
|
||||||
|
|
||||||
|
Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass.
|
||||||
|
|
||||||
|
## Push Procedure
|
||||||
|
|
||||||
|
1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented.
|
||||||
|
2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it.
|
||||||
|
3. Push normally first so the pre-push hook can run.
|
||||||
|
4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response.
|
||||||
|
5. After push, verify the remote ref matches local HEAD.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git rev-parse HEAD origin/$(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
For GitHub PRs, check CI after push:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh pr checks
|
||||||
|
```
|
||||||
|
|
||||||
|
If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good.
|
||||||
4
.agents/skills/dsh-pre-push-checks/agents/openai.yaml
Normal file
4
.agents/skills/dsh-pre-push-checks/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "DSH Pre-Push Checks"
|
||||||
|
short_description: "Run the right DeepSeek Harness gates before push"
|
||||||
|
default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."
|
||||||
@@ -92,6 +92,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.
|
- **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).
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.
|
||||||
|
|||||||
@@ -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
|
* App config: the swappable per-deployment values. `model` configures the
|
||||||
* agent template the ACP bridge creates each session's agent from (NOT a
|
* 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
|
* 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.
|
* `persistenceRoot` is the JSONL backend's directory.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -48,12 +49,14 @@ export interface Config {
|
|||||||
model: string
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
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`. */
|
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||||
persistenceRoot?: string
|
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`
|
## `@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 —
|
* 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
|
* `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
|
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||||
* deployment's persona section). Both are optional INPUT here because each
|
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||||
* owner's schema supplies the default (`[]` / `''`); the schema is the
|
* order). Every field is optional INPUT here because each owner's schema
|
||||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
* supplies the default (`[]` / `''` / absent — lexicographic); the schema is
|
||||||
* never drift from them.
|
* the INTERSECTION of the owners' own schemas, so validation and defaulting
|
||||||
|
* can never drift from them.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||||
agents?: AgentLoopConfig['agents']
|
agents?: AgentLoopConfig['agents']
|
||||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||||
persona?: SystemPromptConfig['persona']
|
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)
|
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`
|
## `@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
|
* App config: the swappable per-demo values, each routed to where the app wires
|
||||||
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
* {@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.
|
* `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -415,6 +422,8 @@ export interface Config {
|
|||||||
model: string
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
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`. */
|
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||||
persistenceRoot?: string
|
persistenceRoot?: string
|
||||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
/** 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`
|
## `@deepseek-ai/dsh-subagent-acp`
|
||||||
|
|
||||||
@@ -565,10 +574,33 @@ export interface Config {
|
|||||||
* deployment opens with the harness identity alone.
|
* deployment opens with the harness identity alone.
|
||||||
*/
|
*/
|
||||||
persona?: string
|
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`
|
## `@deepseek-ai/dsh-tool-fs`
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections,
|
|||||||
section(section: PromptSection): () => void
|
section(section: PromptSection): () => void
|
||||||
tools(provider: () => ToolSchema[]): () => void
|
tools(provider: () => ToolSchema[]): () => void
|
||||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => 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`
|
## `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
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
|||||||
| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.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 |
|
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
|
||||||
| [Dynamic workflows — a script-driven multi-agent orchestration seam](implemented/feature/2026-07-05-dynamic-workflows.md) | 2026-07-05 |
|
| [Dynamic workflows — a script-driven multi-agent orchestration seam](implemented/feature/2026-07-05-dynamic-workflows.md) | 2026-07-05 |
|
||||||
|
| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
|
||||||
|
|
||||||
### Simplification
|
### Simplification
|
||||||
|
|
||||||
|
|||||||
@@ -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}}`.
|
||||||
@@ -35,11 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
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'.
|
// 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
|
## 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 —
|
* 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
|
* `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
|
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||||
* deployment's persona section). Both are optional INPUT here because each
|
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||||
* owner's schema supplies the default (`[]` / `''`); the schema is the
|
* order). Every field is optional INPUT here because each owner's schema
|
||||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
* supplies the default (`[]` / `''` / absent — lexicographic); the schema is
|
||||||
* never drift from them.
|
* the INTERSECTION of the owners' own schemas, so validation and defaulting
|
||||||
|
* can never drift from them.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||||
agents?: AgentLoopConfig['agents']
|
agents?: AgentLoopConfig['agents']
|
||||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||||
persona?: SystemPromptConfig['persona']
|
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. */
|
/** 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;
|
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||||
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
||||||
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
|
* forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
|
||||||
* its `inject` until the services it needs exist), but the listing mirrors the
|
* each fiber on its `inject` until the services it needs exist), but the
|
||||||
* dependency layering for readability: the LLM vocabulary and core registries
|
* listing mirrors the dependency layering for readability: the LLM vocabulary
|
||||||
* first, then the dev tripwire and the bash tool consumer, then the loop that
|
* and core registries first, then the dev tripwire and the bash tool consumer,
|
||||||
* drives them.
|
* then the loop that drives them.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config): void {
|
export function apply(ctx: Context, config: Config): void {
|
||||||
ctx.plugin(Timer)
|
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
|
// The forwarded fields are validated + defaulted by this bundle's intersected
|
||||||
// schema before apply runs, so the ?? fallbacks only narrow the
|
// schema before apply runs, so the ?? fallbacks only narrow the
|
||||||
// optional-input TYPES — they mirror the owners' schema defaults, never
|
// optional-input TYPES — they mirror the owners' schema defaults, never
|
||||||
// introduce different ones.
|
// introduce different ones. toolOrder has no owner-supplied default value —
|
||||||
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
|
// 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(ToolRegistry)
|
||||||
ctx.plugin(AgentRegistry)
|
ctx.plugin(AgentRegistry)
|
||||||
ctx.plugin(invariants)
|
ctx.plugin(invariants)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as agentCore from '../src/index.ts'
|
import * as agentCore from '../src/index.ts'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
@@ -67,6 +68,23 @@ describe('dsh-agent-core bundle', () => {
|
|||||||
await ctx.fiber.dispose()
|
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', () => {
|
it('re-exports the loop config schema as its own', () => {
|
||||||
expect(agentCore.Config).toBeDefined()
|
expect(agentCore.Config).toBeDefined()
|
||||||
expect(agentCore.name).toBe('agent-core')
|
expect(agentCore.name).toBe('agent-core')
|
||||||
|
|||||||
117
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
117
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,15 +7,16 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
|
|||||||
| Key | Default | Meaning |
|
| 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. |
|
| `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`)
|
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||||
|
|
||||||
### Public API
|
### Public API
|
||||||
|
|
||||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
|
- `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.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
|
### Events
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ export interface AssembledSection {
|
|||||||
*
|
*
|
||||||
* Tool schemas are part of the assembly by design: "what the model is told it
|
* 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
|
* 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
|
* `variables` carries every registered prompt variable resolved against this
|
||||||
* assembly's context — key present means registered, `undefined` value means
|
* 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). */
|
/** A complete `{{...}}` reference group at the scan position (validated after). */
|
||||||
const GROUP_AT = /^\{\{([^{}]*)\}\}/
|
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). */
|
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +190,29 @@ export interface Config {
|
|||||||
* deployment opens with the harness identity alone.
|
* deployment opens with the harness identity alone.
|
||||||
*/
|
*/
|
||||||
persona?: string
|
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 {
|
export class SystemPrompt extends Service {
|
||||||
static Config: z<Config> = z.object({
|
static Config: z<Config> = z.object({
|
||||||
persona: z.string().default(''),
|
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 sections: PromptSection[] = []
|
||||||
private toolProviders: (() => ToolSchema[])[] = []
|
private toolProviders: (() => ToolSchema[])[] = []
|
||||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||||
|
private readonly toolOrder: string[] | undefined
|
||||||
|
|
||||||
constructor(ctx: Context, public config: Config) {
|
constructor(ctx: Context, public config: Config) {
|
||||||
super(ctx, 'systemPrompt')
|
super(ctx, 'systemPrompt')
|
||||||
|
this.toolOrder = validateToolOrder(config.toolOrder)
|
||||||
// The harness-owned openers. They live HERE (not on the loop plugin) so a
|
// 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
|
// deployment that swaps in a different loop keeps them: the identity is a
|
||||||
// harness fact stated ahead of everything, and the persona is the
|
// 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
|
* Contribute a tool-schema provider that is evaluated at each assembly
|
||||||
* call (so it can reflect the live registry state). The provider is
|
* 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.
|
* @param provider - evaluated at every {@link assemble} for fresh schemas.
|
||||||
* @returns the disposer that removes the provider.
|
* @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
|
* Assemble the current prompt for one caller: section texts are resolved
|
||||||
* against `context` and sorted by order, tools collected from all
|
* against `context` and sorted by order, tools collected from all providers
|
||||||
* providers, and every registered variable resolved against `context` into
|
* and put in the canonical model-facing order ({@link Config.toolOrder}, or
|
||||||
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
|
* lexicographic name order when unconfigured — provider registration order
|
||||||
* request waterfalls may mutate schema objects. Runs through the
|
* is a plugin-load artifact and never reaches the assembly; a configured
|
||||||
* `system-prompt/assemble` waterfall, giving listeners the opportunity to
|
* order naming a tool no provider contributed rejects the assembly), and every
|
||||||
* mutate or replace the assembly before it reaches the model. Await the
|
* registered variable resolved against `context` into `assembly.variables`.
|
||||||
* result before reading the assembly values — waterfall listeners may be
|
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||||
* async. Interpolation happens later, in {@link renderPrompt}.
|
* 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;
|
* @param context - what this assembly is for (defaults to an empty context;
|
||||||
* see {@link AssembleContext}).
|
* see {@link AssembleContext}).
|
||||||
* @returns the assembly after the waterfall has run.
|
* @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> = {}
|
const variables: Record<string, string | undefined> = {}
|
||||||
for (const [name, provider] of this.variableProviders) {
|
for (const [name, provider] of this.variableProviders) {
|
||||||
variables[name] = provider(context)
|
variables[name] = provider(context)
|
||||||
@@ -348,8 +457,10 @@ export class SystemPrompt extends Service {
|
|||||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.order - b.order),
|
.sort((a, b) => a.order - b.order),
|
||||||
tools: this.toolProviders.flatMap(provider =>
|
tools: orderTools(
|
||||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
this.toolProviders.flatMap(provider =>
|
||||||
|
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||||
|
this.toolOrder),
|
||||||
variables,
|
variables,
|
||||||
}
|
}
|
||||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||||
|
|||||||
115
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
115
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 |
|
| `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` |
|
| `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 |
|
| `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`).
|
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`).
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||||
"cordis": "^4.0.0-rc.6",
|
"cordis": "^4.0.0-rc.6",
|
||||||
"schemastery": "^3.17.0"
|
"schemastery": "^3.17.0"
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export const name = 'acp-agent'
|
|||||||
* App config: the swappable per-deployment values. `model` configures the
|
* App config: the swappable per-deployment values. `model` configures the
|
||||||
* agent template the ACP bridge creates each session's agent from (NOT a
|
* 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
|
* 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.
|
* `persistenceRoot` is the JSONL backend's directory.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -50,6 +51,8 @@ export interface Config {
|
|||||||
model: string
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
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`. */
|
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||||
persistenceRoot?: string
|
persistenceRoot?: string
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,10 @@ export interface Config {
|
|||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
persona: z.string(),
|
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'),
|
persistenceRoot: z.string().default('./.sessions'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,6 +77,7 @@ export const Config: z<Config> = z.object({
|
|||||||
export function apply(ctx: Context, config: Config): void {
|
export function apply(ctx: Context, config: Config): void {
|
||||||
ctx.plugin(agentCore, {
|
ctx.plugin(agentCore, {
|
||||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||||
|
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||||
})
|
})
|
||||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||||
ctx.plugin(acp, { model: config.model })
|
ctx.plugin(acp, { model: config.model })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as acpAgent from '../src/index.ts'
|
import * as acpAgent from '../src/index.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +53,27 @@ describe('dsh-acp-agent composition', () => {
|
|||||||
expect(acpAgent.Config).toBeDefined()
|
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', () => {
|
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
|
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
|
||||||
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
|
// `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 |
|
| `model` | (required) | the pre-created `main` agent's model |
|
||||||
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
|
| `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 |
|
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||||
| `welcome` | `ready.` | the stdin-chat banner |
|
| `welcome` | `ready.` | the stdin-chat banner |
|
||||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||||
"@deepseek-ai/dsh-session": "workspace:^",
|
"@deepseek-ai/dsh-session": "workspace:^",
|
||||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||||
"cordis": "^4.0.0-rc.6",
|
"cordis": "^4.0.0-rc.6",
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export const name = 'stdio-agent'
|
|||||||
* App config: the swappable per-demo values, each routed to where the app wires
|
* App config: the swappable per-demo values, each routed to where the app wires
|
||||||
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
* {@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.
|
* `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -61,6 +62,8 @@ export interface Config {
|
|||||||
model: string
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
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`. */
|
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||||
persistenceRoot?: string
|
persistenceRoot?: string
|
||||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||||
@@ -76,6 +79,10 @@ export interface Config {
|
|||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
persona: z.string(),
|
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'),
|
persistenceRoot: z.string().default('./.sessions'),
|
||||||
welcome: z.string().default('ready.'),
|
welcome: z.string().default('ready.'),
|
||||||
resumeSessionId: z.string(),
|
resumeSessionId: z.string(),
|
||||||
@@ -92,6 +99,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
ctx.plugin(ConsoleExporter)
|
ctx.plugin(ConsoleExporter)
|
||||||
ctx.plugin(agentCore, {
|
ctx.plugin(agentCore, {
|
||||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||||
|
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||||
agents: [{
|
agents: [{
|
||||||
id: AgentId('main'),
|
id: AgentId('main'),
|
||||||
model: config.model,
|
model: config.model,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as stdioAgent from '../src/index.ts'
|
import * as stdioAgent from '../src/index.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +75,27 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
expect(stdioAgent.Config).toBeDefined()
|
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', () => {
|
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
|
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
|
||||||
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
|
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -899,6 +899,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../session-persistence/session-persistence-jsonl
|
version: link:../../session-persistence/session-persistence-jsonl
|
||||||
|
'@deepseek-ai/dsh-system-prompt':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/system-prompt
|
||||||
cordis:
|
cordis:
|
||||||
specifier: ^4.0.0-rc.6
|
specifier: ^4.0.0-rc.6
|
||||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
|
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':
|
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../session-persistence/session-persistence-jsonl
|
version: link:../../session-persistence/session-persistence-jsonl
|
||||||
|
'@deepseek-ai/dsh-system-prompt':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/system-prompt
|
||||||
cordis:
|
cordis:
|
||||||
specifier: ^4.0.0-rc.6
|
specifier: ^4.0.0-rc.6
|
||||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
|
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
|
||||||
@@ -2455,6 +2461,9 @@ packages:
|
|||||||
'@types/tough-cookie@4.0.5':
|
'@types/tough-cookie@4.0.5':
|
||||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
@@ -5185,6 +5194,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/tough-cookie@4.0.5': {}
|
'@types/tough-cookie@4.0.5': {}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@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)':
|
'@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)':
|
||||||
@@ -5278,7 +5290,10 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.61.0
|
'@typescript-eslint/types': 8.61.0
|
||||||
eslint-visitor-keys: 5.0.1
|
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)':
|
'@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5668,7 +5683,9 @@ snapshots:
|
|||||||
|
|
||||||
diff@9.0.0: {}
|
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):
|
dts-resolver@3.0.0(oxc-resolver@11.20.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"AGENTS.md": 1660,
|
"AGENTS.md": 1690,
|
||||||
"docs/AGENTS.md": 1315,
|
"docs/AGENTS.md": 1315,
|
||||||
"docs/architecture.md": 1630,
|
"docs/architecture.md": 1630,
|
||||||
"docs/cordis-primer.md": 550,
|
"docs/cordis-primer.md": 550,
|
||||||
|
|||||||
Reference in New Issue
Block a user