diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..017fada6b8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,37 @@ +# DeepSeek Harness + +Ubiquitous language for the harness. Started during the 2026-07-05 system-prompt redesign session; grows as terms crystallize. Decisions live in `docs/rfc/` (this repo's ADR equivalent), not here. + +## Language — prompt assembly + +**Section**: +One named, ordered fragment of the system prompt, contributed by a plugin through `ctx.systemPrompt.section()`. +_Avoid_: block, snippet + +**Assembly**: +The collated output of `assemble()` — sections, tool schemas, and resolved prompt variables — before rendering. + +**Full system prompt**: +The rendered text the model actually receives: all sections interpolated and joined. There is no other composition path. +_Avoid_: using "system prompt" for any single fragment + +**Persona**: +The per-agent, deployment-authored prompt fragment (config key `systemPrompt` on an agent). A template, not final text; rendered as the order-0 section. It is one section of the full system prompt, never the whole. +_Avoid_: calling it "the system prompt" + +**Prompt variable**: +A named per-assembly value contributed by a plugin (e.g. `model`) and referenced from section or persona text as `{{name}}`. +_Avoid_: placeholder, macro + +**Assemble context**: +The per-agent input to one `assemble()` call, carrying which agent the prompt is for. Merge-extensible; variable providers and section text providers are functions of it. + +**Tool guidance**: +The model-facing usage prose for one tool, owned by the tool's package as a section (order band 100–199) — never hand-written in leaf config. +_Avoid_: tool prompt, tool docs + +## Language — subagents + +**Context contract**: +Whether a subagent provider's child sees the parent conversation (`inheritsParentContext`): fork inherits the log, spawn and ACP start fresh. Declared by the provider, consumed by tool wording. + diff --git a/docs/architecture.md b/docs/architecture.md index 44302fb726..c9090c185a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source ## Prompt assembly (dsh-system-prompt) -Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers; `assemble()` returns `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall. Tool schemas are deliberately part of the assembly — "what the model is told it can do" is one coherent thing — though adapters transmit them as the wire-level `tools` field ([RFC](rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md)). +Plugins contribute `PromptSection`s (named, ordered, static or computed from the per-call `AssembleContext`), tool-schema providers, and named **prompt variables** interpolated as `{{name}}` at render (strict: an unknown or valueless reference throws). `renderPrompt(assemble({ agent }))` IS the full prompt: the loop's `agent:persona` section (order 0) and its `model`/`cwd` variables carry the per-agent facts — no second composition path. Tool schemas are deliberately part of the assembly ([RFC](rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md)); prompt-fact ownership (persona vs description vs section vs variable) is pinned by [the prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). ## Tool pipeline (dsh-tools) @@ -99,7 +99,8 @@ forever: every prompt blocked → 'turn/end'(rejected), 0 steps ⟵ zero-step turn, model never called STEP loop: drain steering (late steering from previous step's listeners) - assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt + (persona section + {{variables}}) IS the prompt await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step session('step/start') ⟵ durable step boundary (no agent/* mirror) req = {model, system, tools, messages: session.deriveMessages(), signal} diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 922a0ea018..8433f5c9ab 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:380`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) #### `agent/prompt-submit` — waterfall @@ -75,7 +75,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) #### `agent/session-start` — emit @@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -147,7 +147,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `fs/*` @@ -261,23 +261,23 @@ Source: [`packages/subagent/subagent/src/index.ts:70`](../../packages/subagent/s #### `system-prompt/assemble` — waterfall -Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tool schemas) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. +Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. ```ts cordis-catalog -'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise +'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:26`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:32`](../../packages/core/system-prompt/src/index.ts) #### `system-prompt/change` — emit -A section or tool provider was registered or unregistered (the assembly inputs changed). +A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed). ```ts cordis-catalog 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:32`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:38`](../../packages/core/system-prompt/src/index.ts) ### `tools/*` @@ -490,15 +490,16 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/ ### `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. ```ts cordis-catalog section(section: PromptSection): () => void tools(provider: () => ToolSchema[]): () => void -assemble(): Promise +variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void +assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:73`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:149`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 1d998b60b7..9ad875075c 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -75,12 +75,13 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. ```ts type-equiv interface SubagentProvider { readonly name: string readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean start(request: SubagentStartRequest): SubagentRun } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 50ef532fa4..c4e6fbdccf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -21,6 +21,7 @@ graph TD agent --> brand agent --> llm agent --> session + agent --> system-prompt compact --> llm compact --> session fs-local --> fs @@ -70,6 +71,7 @@ graph TD tool-bash --> agent tool-bash --> bash tool-bash --> llm + tool-bash --> system-prompt tool-bash --> tools tool-fs --> fs tool-fs --> llm @@ -142,7 +144,7 @@ graph TD | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `web` | `llm` | -| `agent` | `brand`, `llm`, `session` | +| `agent` | `brand`, `llm`, `session`, `system-prompt` | | `compact` | `llm`, `session` | | `fs-local` | `fs` | | `fs-policy` | `fs` | @@ -162,7 +164,7 @@ graph TD | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `hooks-codex` | `agent`, `hook-protocol`, `llm`, `session`, `tools` | | `subagent` | `agent`, `llm`, `tools` | -| `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tool-bash` | `agent`, `bash`, `llm`, `system-prompt`, `tools` | | `tool-fs` | `fs`, `llm`, `session`, `system-prompt`, `tools` | | `tool-todo` | `agent`, `session`, `tools` | | `tool-web` | `llm`, `system-prompt`, `tools`, `web` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 54fdd5de70..9752c22c08 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -152,6 +152,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 | | [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | | [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 | +| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md new file mode 100644 index 0000000000..14b9990881 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -0,0 +1,69 @@ +# RFC: Prompt variables and tool-guidance ownership + +Status: implemented (proposed and accepted 2026-07-05) + +## Problem + +The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted. + +**The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. + +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. + +**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are coding-agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. + +**The fork tool's description was false.** `dsh-tool-subagent` hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the `subagent_fork` instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: `PromptSection.name` was documented "(diagnostics / dedup)" but duplicates were silently accepted. + +## Decision + +**One principle: every fact in the prompt has exactly one owner.** The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's `description`. Cross-call habits a description cannot carry → the tool package's prompt section. Identity and behavior → the deployment's persona, and nothing else. + +### Assemble context + +`SystemPrompt.assemble(context)` takes an `AssembleContext` — declared EMPTY and merge-extensible in `dsh-system-prompt` (the package stays agnostic of who assembles); `dsh-agent` declaration-merges `agent?: Agent` onto it (a new type-level edge `agent → system-prompt`, no cycle — `tools` already depends on both). The loop passes `{ agent }` each step; section text providers become `string | ((context) => string)` (zero-arg providers stay valid), and the `system-prompt/assemble` waterfall gains the context parameter so a listener can filter or extend per agent. + +### Prompt variables + +Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists), a registered-but-valueless reference throws, and a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws. Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. + +`dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). + +### Persona as the order-0 section + +The loop plugin registers ONE section, `agent:persona` at order 0, whose text is `context.agent?.options.systemPrompt ?? ''`. The loop's special-case join is deleted: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. Order bands are now convention: persona `0`, tool guidance `100–199`, negative orders render before the persona. `AgentOptions.systemPrompt` keeps its familiar key but is documented as what it is — the persona template fragment, one section of the full prompt, never the whole (see `CONTEXT.md`). + +### Tool guidance ownership + +Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. + +### The subagent context contract + +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. `apply` resolves the provider at LOAD time and throws if it is not registered — the backend plugin must be listed before the tool plugin in `cordis.yml`; a wiring mistake fails loudly at boot instead of shipping a lying description. + +## Rejected alternatives + +- **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and contradicts `dsh-system-prompt`'s "no hardcoded prompt text" stance. +- **Inject the model name via the `agent/request` waterfall** — prompt text composed in two places, and `agent/pre-step`'s `fullSystemPrompt` would omit it, so compaction would measure a prompt that is not what the model sees. +- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this RFC cures. +- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. +- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. +- **Resolving the subagent flag lazily (section-only wording)** — would tolerate any load order, but the DESCRIPTION is fixed at tool registration and is where tool-choice guidance belongs; a deterministic load-order requirement with a loud, actionable failure is the smaller cost. + +## What we give up + +- `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step; such a plugin can rewrite `options.system` in the same waterfall if it cares. Accepted. +- `dsh-tool-subagent` now has a hard load-order requirement on its backend. The examples already ordered backends first; the failure mode is an immediate boot error naming the fix. +- Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud. +- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it. + +## Out of scope + +- Further variables (`date`, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here. +- A config `cwd` for pre-created stdio agents (would let the stdio persona use `{{cwd}}` and partition persistence by real path) — deferred until the session-cwd story is revisited. + +## Acceptance criteria + +- `renderPrompt(assemble({agent}))` for the coding-agent example contains the persona FIRST (with the agent's model name interpolated), then fs/bash/web guidance sections; the loop contains no other prompt-composition path. +- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. Loading `dsh-tool-subagent` before its backend fails at load with a message naming the ordering fix. +- Unknown/valueless/malformed `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. +- The gating runs (`test:coverage`, `test:snapshot`, `doc-sync`, `build`, `hygiene`) are green; no golden re-record is needed (replay never re-verifies the outgoing request). diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 01849bb66e..ca85698c36 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -23,9 +23,8 @@ - deepseek-v4-flash - deepseek-v4-pro -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; filesystem, subagent, and todo_write are loaded below. +# Local bash executor for agent-core's tool-bash schema (one of several tool +# stacks in this tree: filesystem, subagent, and todo_write load below). - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -39,28 +38,16 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + # The persona: identity + behavior only. Tool guidance lives with each tool + # plugin (descriptions + prompt sections); {{model}} and {{cwd}} are prompt + # variables the agent loop resolves per session (every ACP session carries + # the client's cwd, so the persona can state the workspace). systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. + You are a coding assistant powered by the {{model}} model, driven over + the Agent Client Protocol. Your working directory is {{cwd}}. - Your tools are read/write/edit for file operations, bash (plus - bash_output/bash_kill for background tasks), and subagent. Use read to - inspect UTF-8 text files, write to create or replace files, and edit for - targeted literal replacements. Use bash for shell commands, tests, - searches, and operations that are not ordinary file reads or edits. Each - bash call runs in a fresh shell — pass workdir instead of cd. Check the - [exit code: N] marker; verify your work. Keep answers brief and factual. - - Use the subagent tool to delegate a focused, self-contained subtask to - a fresh child agent (it works in its own context and returns only its - final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. + Verify your work by running the code or tests. Keep answers brief and + factual. # The subagent seam + both in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 0c95299fca..1980c10aa6 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -28,9 +28,8 @@ - deepseek-v4-pro - deepseek-v4-flash -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; filesystem, subagent, and todo_write are loaded below. +# Local bash executor for agent-core's tool-bash schema (one of several tool +# stacks in this tree: filesystem, subagent, and todo_write load below). - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -46,33 +45,15 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).' + welcome: 'agent REPL ready. Give it a coding task.' + # The persona: identity + behavior only. Tool guidance lives with each tool + # plugin (descriptions + prompt sections); {{model}} is the prompt variable + # the agent loop resolves from this agent's configured model. systemPrompt: | - You are coding-agent, a CLI coding assistant. + You are coding-agent, a CLI coding assistant powered by the {{model}} model. - Your tools are read/write/edit for file operations, bash (plus - bash_output/bash_kill for background tasks), and subagent. Use read to - inspect UTF-8 text files, write to create or replace files, and edit for - targeted literal replacements. Use bash for shell commands, tests, - searches, and operations that are not ordinary file reads or edits. Each - bash call runs in a fresh shell — pass workdir instead of cd, and never - rely on shell state between calls. - - Use the subagent tool to delegate a focused, self-contained subtask - to a fresh child agent (it works in its own context and returns only - its final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - Check the [exit code: N] marker on every command; investigate - failures before moving on. Verify your work by running the code or - tests. Keep answers brief and factual. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. + Verify your work by running the code or tests. Keep answers brief and + factual. # Automatic context compaction: when the derived history approaches the model's # context window, summarize an older range into a checkpoint so a long-running diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 436e3f034a..eabb9298aa 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -2,7 +2,9 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). + +The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. ## Tools diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index f9092fabb6..d8836d6a21 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 88d14cc0f0..e208fbeb06 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -43,11 +43,12 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' -export const inject = ['tools', 'bash'] +export const inject = ['tools', 'bash', 'systemPrompt'] /** * Validate the constraints the SchemaSpec can't express. `defineTool` now @@ -285,6 +286,15 @@ function statusLine(task: BashTask): string { } export function apply(ctx: Context): void { + // The bash tools' cross-call HABIT, which the per-tool descriptions cannot + // carry (they describe one call each): the exit-code marker is only useful + // if the model actually checks it every time. + ctx.systemPrompt.section({ + name: 'tool:bash', + order: 105, + text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.', + }) + /** * The caller's owner TOKEN — the owning agent's `session.header.id`, or * `undefined` for a non-agent caller. Read `session.header.id` (NOT diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 0187302ef1..f6a985f594 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -261,6 +261,14 @@ describe('bash tool', () => { }) }) + it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => { + const ctx = await setup() + const assembly = await ctx.systemPrompt.assemble() + const section = assembly.sections.find(s => s.name === 'tool:bash') + expect(section?.order).toBe(105) + expect(section?.text).toContain('[exit code: N]') + }) + it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -268,8 +276,10 @@ describe('bash tool', () => { await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(3) + expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['tool:bash']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) + expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) }) it('tools depend on the executor: no registration without ctx.bash', async () => { diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 60b8a5d779..fec733c34e 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -28,12 +28,12 @@ interface Config { agents: Array<{ id: string // required model?: string - systemPrompt?: string + systemPrompt?: string // the agent's persona TEMPLATE (may reference {{model}}/{{cwd}}) }> } ``` -Agents listed in config are auto-created at startup. +Agents listed in config are auto-created at startup. The plugin also registers the per-agent prompt pieces on `ctx.systemPrompt`: the `agent:persona` section (order 0 — `AgentOptions.systemPrompt` renders before all tool guidance) and the built-in `model`/`cwd` prompt variables, each resolved per step from the `assemble({ agent })` context. ### Classes @@ -55,7 +55,7 @@ forever: if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn STEP loop: drain steering - assembly = systemPrompt.assemble() + assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt await serial agent/pre-step ⟵ surface mutation (compaction) outside the step session('step/start') request = waterfall agent/request diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33672dc9b4..8bc35a8f93 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -82,6 +82,20 @@ export class AgentLoop extends Service implements AgentFactory { // Provide the agent-creation factory to the registry (effect-scoped: the // slot is cleared on dispose). ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()') + // The per-agent prompt pieces, registered once and resolved per assembly + // from the AssembleContext the loop passes (loop.ts assembles with + // `{ agent }` each step). The persona is the order-0 section — identity + // renders before all tool guidance; `{{model}}`/`{{cwd}}` are the built-in + // prompt variables projecting the agent's configured model and its + // session workspace. A provider returns undefined when the fact is absent + // (renderPrompt then rejects a persona that claims it — fail loud). + ctx.systemPrompt.section({ + name: 'agent:persona', + order: 0, + text: context => context.agent?.options.systemPrompt ?? '', + }) + ctx.systemPrompt.variable('model', context => context.agent?.options.model) + ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) for (const { id, resumeSessionId, ...options } of config.agents) { if (resumeSessionId !== undefined && resumeSessionId !== '') { // Resume a prior session instead of starting fresh. resume() needs diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ef5c50b7ce..0b84bcb83f 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -152,7 +152,8 @@ export interface LoopHandle { * every prompt blocked → 'turn/end'(rejected), 0 steps * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt + * (persona section + {{variables}}) IS the full prompt * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step * session('step/start') ⟵ durable step boundary (no agent/* mirror) * req = {model, system, tools, messages: session.deriveMessages(), signal} @@ -434,11 +435,11 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // because the pre-step seam needs it: compaction measures token pressure // against the system prompt (it counts toward the budget). runStep reuses // this same assembly for the request, so the prompt is assembled once per - // step. - const assembly = await ctx.systemPrompt.assemble() - const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') + // step. renderPrompt IS the full prompt — the persona is the order-0 + // section (registered by the AgentLoop plugin) and `{{variable}}` + // interpolation happens in the render, so there is no separate join. + const assembly = await ctx.systemPrompt.assemble({ agent }) + const fullSystemPrompt = renderPrompt(assembly) // Interruption landing after assembly: dispose() or cancel() in a // turn-start listener (or a listener whose promise resolved before the diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 934c19953a..014ddb548c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -141,10 +141,10 @@ describe('agent loop', () => { .toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] }) }) - it('passes assembled system prompt and tool schemas into the request', async () => { + it('renders the persona as the order-0 section — before tool guidance — with {{variables}} resolved', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' }) + ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) ctx.tools.register(defineTool({ name: 'noop', description: 'does nothing', @@ -153,16 +153,55 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) + // The persona is a TEMPLATE: {{model}} is the loop-registered variable + // projecting this agent's configured model, so the model knows its own name. + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'You are a test agent on {{model}}.' }) send(agent, 'hi') await waitForIdle(ctx, agent) const request = adapter.requests[0] - expect(request!.system).toBe('You are a test agent.\n\nAgent-specific suffix.') + expect(request!.system).toBe('You are a test agent on mock.\n\nUse the noop tool wisely.') expect(request!.tools?.map(t => t.name)).toEqual(['noop']) }) + it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const handle = ctx.agents.create({ + agentId: AgentId('a-cwd'), + sessionId: SessionId('s-cwd'), + meta: { cwd: '/work/space' }, + agentOptions: { model: 'mock', systemPrompt: 'Working in {{cwd}}.' }, + }) + + const agent = handle.agent as ReactLoopAgent + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]!.system).toBe('Working in /work/space.') + }) + + it('contains a strict-variable render failure: the turn errors, the loop survives', async () => { + // A persona claiming {{cwd}} on a session with NO cwd is a deployment + // authoring error — renderPrompt throws, the turn ends with an error, and + // the agent (and loop) stay alive for the next prompt. + const adapter = new MockAdapter([textResponse('never reached'), textResponse('ok')]) + const ctx = await harness(adapter) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'In {{cwd}}.' }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) // the request was never sent + expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true) + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') + expect(agent.status).toBe('idle') // contained: the loop is still serving + }) + it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 76c377fd61..21be9e8267 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1016,7 +1016,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { ctx.llm.registerAdapter(['mock'], adapter) // Blocking listener on the parent context (survives fiber disposal). - const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocked return next() }) @@ -1072,7 +1072,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(Invariants, { freeze: false }) ctx.llm.registerAdapter(['mock'], adapter) - const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocker return next() }) @@ -1229,7 +1229,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(Invariants, { freeze: false }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('system-prompt/assemble', async function (_assembly, next) { + ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocker return next() }) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index fa6946b8cf..e38a6c8d61 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -25,12 +25,14 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 2a5d713b85..26dde9c764 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -45,6 +45,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -55,6 +56,20 @@ export function AgentId(id: string): AgentId { } import type { Session } from '@deepseek-ai/dsh-session' +declare module '@deepseek-ai/dsh-system-prompt' { + interface AssembleContext { + /** + * The agent this assembly is for. The agent loop passes it on every + * per-step `assemble({ agent })`; section text and variable providers + * project per-agent facts from it (`options.systemPrompt` → the persona + * section, `options.model` → `{{model}}`, `session.header.cwd` → + * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) + * has no agent — providers must tolerate its absence. + */ + agent?: Agent + } +} + /** * Options an agent is created with. * Merge-extensible: plugins declare extra fields via declaration merging. @@ -62,7 +77,13 @@ import type { Session } from '@deepseek-ai/dsh-session' export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string - /** Per-agent system prompt appended after the assembled sections. */ + /** + * The agent's persona: a deployment-authored prompt-template fragment, + * rendered as the order-0 section of the assembled system prompt (before + * all tool guidance). It may reference registered `{{variables}}` (e.g. + * `{{model}}`, `{{cwd}}`); it is one section of the full prompt, never the + * whole. + */ systemPrompt?: string } diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 4d23ac46d3..7f4f457598 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" } ] } diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 1c18bdf1d6..28d382f54a 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,37 +1,42 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. ## Service: `SystemPrompt` (ctx key: `systemPrompt`) ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. 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.assemble(): Promise` Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. ### Events | Event | Mode | Purpose | |---|---|---| -| `system-prompt/assemble` | waterfall | Mutate/extend the assembly before it reaches the model | -| `system-prompt/change` | emit | A section or tool provider was registered or unregistered | +| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model | +| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered | ### Key types -- `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections are concatenated in ascending `order`. -- `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. -- `renderPrompt(assembly)` — joins section texts with blank lines. +- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context). +- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `0` is the per-agent persona (registered by the agent loop), tool guidance uses `100–199`; negative orders render before the persona. +- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. +- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference, a registered-but-valueless reference, or a malformed complete `{{…}}` group throws (fail loud beats shipping a malformed prompt). Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim. -Merge-extensible: plugins can declare extra fields on `PromptAssembly` via declaration merging. +Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging. ### Extension points -- Section providers: AGENTS.md reader, cwd notifier, persona config, etc. +- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); the agent loop owns `agent:persona`. +- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The `system-prompt/assemble` waterfall: mutate or replace the assembly (system-prompt configurability, dynamic tool filtering). +- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables). ### What is NOT here -- Any hardcoded prompt text — every section comes from plugins. +- Any hardcoded prompt text — every section comes from plugins, every deployment-authored word from config. - Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). + +Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 37dc10b9ee..3faf2373fa 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,7 +1,8 @@ /** - * System prompt assembly registry. Plugins contribute ordered text sections and - * tool schema providers; `assemble()` collates them through a waterfall that - * runs once per step. + * System prompt assembly registry. Plugins contribute ordered text sections, + * tool schema providers, and named prompt variables; `assemble(context)` + * collates them through a waterfall that runs once per step, and + * `renderPrompt` interpolates `{{variable}}` references into the final text. * * @module @deepseek-ai/dsh-system-prompt */ @@ -17,30 +18,63 @@ declare module 'cordis' { interface Events { /** * Waterfall around prompt assembly — mutate or extend the - * {@link PromptAssembly} (sections + tool schemas) before it is rendered. - * Bound to the {@link SystemPrompt} service; call `next()` to delegate. - * @param assembly - the assembly built from the registered sections and - * tool providers; listeners may mutate it or return a replacement. + * {@link PromptAssembly} (sections + tools + variables) before it is + * rendered. Bound to the {@link SystemPrompt} service; call `next()` to + * delegate. + * @param assembly - the assembly built from the registered sections, tool + * providers, and variable providers; listeners may mutate it or return a + * replacement. + * @param context - the per-assembly {@link AssembleContext} the caller + * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt + * is for), so a listener can filter or extend per agent. * @mode waterfall */ - 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise + 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** - * A section or tool provider was registered or unregistered (the assembly - * inputs changed). + * A section, tool provider, or variable provider was registered or + * unregistered (the assembly inputs changed). * @mode emit */ 'system-prompt/change'(): void } } -/** One contributed section of the system prompt. */ +/** + * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR. + * Declared empty here so this package stays agnostic of who assembles; + * merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so + * section text and variable providers can be functions of the calling agent. + * Every field is optional by nature: a bare `assemble()` (tests, diagnostics) + * carries an empty context, and providers must tolerate absent fields. + */ +export interface AssembleContext {} + +/** One contributed section of the system prompt (registry input). */ export interface PromptSection { - /** Unique name (diagnostics / dedup). */ + /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ name: string - /** Sections are concatenated in ascending order. */ + /** + * Sections are concatenated in ascending order. Convention: `0` is the + * per-agent persona, tool guidance uses 100–199; negative orders render + * before the persona. + */ order: number - /** Static text or a provider evaluated at each assembly. */ - text: string | (() => string) + /** + * Static text or a provider evaluated at each assembly with that assembly's + * {@link AssembleContext}. The text may reference `{{variable}}`s — they are + * interpolated later, by {@link renderPrompt}. + */ + text: string | ((context: AssembleContext) => string) +} + +/** One section of an assembly: {@link PromptSection} with its text resolved. */ +export interface AssembledSection { + /** The contributing section's unique name. */ + name: string + /** The contributing section's order (sections arrive sorted ascending). */ + order: number + /** The resolved (but not yet interpolated) section text. */ + text: string } /** @@ -50,29 +84,72 @@ export interface PromptSection { * can do" is one coherent thing managed here, even though adapters transmit * `tools` as a separate wire field rather than prompt text. * + * `variables` carries every registered prompt variable resolved against this + * assembly's context — key present means registered, `undefined` value means + * "no value for this assembly" (referencing it renders an error). Section + * texts are resolved but NOT yet interpolated; {@link renderPrompt} applies + * the variables, so waterfall listeners can still add sections or variables. + * * Merge-extensible: plugins can declare extra fields on this interface. */ export interface PromptAssembly { - sections: PromptSection[] + sections: AssembledSection[] tools: ToolSchema[] + variables: Record } -/** Renders the text part of an assembly (sections joined by blank lines). */ +/** Valid variable names: how they are written between the braces. */ +const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ + +/** A complete `{{...}}` reference group (any inner content, validated after). */ +const REFERENCE = /\{\{([^{}]*)\}\}/g + +/** + * Renders the text part of an assembly: interpolates `{{variable}}` + * references in each section from `assembly.variables`, drops empty sections, + * and joins the rest with blank lines. + * + * Strict by design (fail loud beats shipping a malformed prompt): a reference + * to an unregistered variable, to a registered variable with no value for + * this assembly, or a complete `{{...}}` group that is not a well-formed + * variable name (e.g. `{{ model }}`) throws. Only complete double-brace + * groups are interpreted; a lone `{{` without a closing `}}` passes through + * verbatim. + */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections - .map(section => typeof section.text === 'function' ? section.text() : section.text) + .map(section => interpolate(section, assembly.variables)) .filter(text => text.length > 0) .join('\n\n') } +/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */ +function interpolate(section: AssembledSection, variables: Record): string { + return section.text.replace(REFERENCE, (_match, name: string) => { + if (!VARIABLE_NAME.test(name)) { + throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`) + } + if (!(name in variables)) { + const known = Object.keys(variables) + throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`) + } + const value = variables[name] + if (value === undefined) { + throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`) + } + return value + }) +} + /** * Registry service (`ctx.systemPrompt`): plugins contribute ordered text - * sections and tool-schema providers; the agent loop calls `assemble()` once - * per step. + * sections, tool-schema providers, and named prompt variables; the agent loop + * calls `assemble(context)` once per step. */ export class SystemPrompt extends Service { private sections: PromptSection[] = [] private toolProviders: (() => ToolSchema[])[] = [] + private variableProviders = new Map string | undefined>() constructor(ctx: Context) { super(ctx, 'systemPrompt') @@ -80,13 +157,18 @@ export class SystemPrompt extends Service { /** * Contribute a text section to the system prompt. Order is determined by - * `section.order` (ascending). The section is removed when the calling + * `section.order` (ascending). Throws if a section with the same name is + * already registered (a duplicate would silently double prompt text — e.g. + * a double-loaded tool plugin). The section is removed when the calling * fiber is disposed. Emits `system-prompt/change` on register/unregister. * @param section - the section to contribute (name, order, text or provider). * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { + if (this.sections.some(existing => existing.name === section.name)) { + throw new Error(`prompt section "${section.name}" is already registered`) + } this.sections.push(section) // Yield the rollback BEFORE emitting `system-prompt/change`: a generator // effect collects each yielded disposer before the next step runs, so a @@ -130,25 +212,71 @@ export class SystemPrompt extends Service { } /** - * Assemble the current prompt (sections sorted by order, tools collected - * from all providers). Section records are top-level clones (the `text` - * provider may be a function and is intentionally shared); tool schemas are - * deep-cloned because adapters and request waterfalls may mutate schema - * objects. Runs through the `system-prompt/assemble` waterfall, giving - * listeners the opportunity to mutate or replace the assembly before it - * reaches the model. Await the result before reading the assembly values — - * waterfall listeners may be async. + * Contribute a named prompt variable, referenced from section text as + * `{{name}}`. The provider is evaluated at each assembly with that + * assembly's {@link AssembleContext}; returning `undefined` means "no value + * for this assembly" (a section referencing it then fails to render — a + * deployment must not claim facts it does not have). Throws on a name that + * does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is + * already registered. Removed when the calling fiber is disposed; emits + * `system-prompt/change` on register/unregister. + * @param name - the reference name (matches `[a-z][a-z0-9_]*`). + * @param provider - evaluated at every {@link assemble} for the value. + * @returns the disposer that removes the variable. + */ + variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { + const dispose = this.ctx.effect(function* (this: SystemPrompt) { + if (!VARIABLE_NAME.test(name)) { + throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) + } + if (this.variableProviders.has(name)) { + throw new Error(`prompt variable "${name}" is already registered`) + } + this.variableProviders.set(name, provider) + // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + yield () => { + this.variableProviders.delete(name) + this.ctx.emit('system-prompt/change') + } + this.ctx.emit('system-prompt/change') + }.bind(this), 'systemPrompt.variable()') + // ctx.effect's disposer returns Promise; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** + * Assemble the current prompt for one caller: section texts are resolved + * against `context` and sorted by order, tools collected from all + * providers, and every registered variable resolved against `context` into + * `assembly.variables`. Tool schemas are deep-cloned because adapters and + * request waterfalls may mutate schema objects. Runs through the + * `system-prompt/assemble` waterfall, giving listeners the opportunity to + * mutate or replace the assembly before it reaches the model. Await the + * result before reading the assembly values — waterfall listeners may be + * async. Interpolation happens later, in {@link renderPrompt}. + * @param context - what this assembly is for (defaults to an empty context; + * see {@link AssembleContext}). * @returns the assembly after the waterfall has run. */ - assemble(): Promise { + assemble(context: AssembleContext = {}): Promise { + const variables: Record = {} + for (const [name, provider] of this.variableProviders) { + variables[name] = provider(context) + } const assembly: PromptAssembly = { sections: this.sections - .map(section => ({ ...section })) + .map(section => ({ + name: section.name, + order: section.order, + text: typeof section.text === 'function' ? section.text(context) : section.text, + })) .sort((a, b) => a.order - b.order), tools: this.toolProviders.flatMap(provider => provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))), + variables, } - return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly)) + return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly)) } } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index cfbf36cbec..ce6760cd20 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SystemPrompt, { PromptAssembly, PromptSection, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt' describe('SystemPrompt', () => { - it('assembles sections in order with dynamic text and collected tools', async () => { + it('assembles sections in order with context-resolved text and collected tools', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -14,10 +14,28 @@ describe('SystemPrompt', () => { const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.map(s => s.name)).toEqual(['persona', 'rules', 'cwd']) + expect(assembly.sections.map(s => s.text)).toEqual(['You are DeepSeek Code.', 'Be precise.', 'cwd: /tmp']) expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }]) + expect(assembly.variables).toEqual({}) expect(renderPrompt(assembly)).toBe('You are DeepSeek Code.\n\nBe precise.\n\ncwd: /tmp') }) + it('resolves section text providers against the assemble context, at each assemble call', async () => { + // The context is HOW per-agent sections work (the loop passes { agent }); + // this spec stays agent-agnostic and smuggles a marker through a plain field. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let calls = 0 + ctx.systemPrompt.section({ + name: 'dynamic', + order: 0, + text: (context: AssembleContext) => `call ${++calls} for ${(context as { who?: string }).who ?? 'nobody'}`, + }) + + expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).sections[0]!.text).toBe('call 1 for alice') + expect((await ctx.systemPrompt.assemble()).sections[0]!.text).toBe('call 2 for nobody') + }) + it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -25,13 +43,28 @@ describe('SystemPrompt', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' }) inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }]) + inner.systemPrompt.variable('scoped_var', () => 'v') }, { inject: ['systemPrompt'] })) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1) + const before = await ctx.systemPrompt.assemble() + expect(before.sections).toHaveLength(1) + expect(before.variables).toEqual({ scoped_var: 'v' }) await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections).toHaveLength(0) expect(assembly.tools).toHaveLength(0) + expect(assembly.variables).toEqual({}) + }) + + it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' }) + expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' })) + .toThrow('prompt section "dup" is already registered') + // The failed registration leaked nothing; the original stays intact. + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(s => s.text)).toEqual(['first']) }) it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => { @@ -72,26 +105,47 @@ describe('SystemPrompt', () => { expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t']) }) - it('composes multiple system-prompt/assemble waterfall listeners in order', async () => { + it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + + let threw = false + const off = ctx.on('system-prompt/change', () => { + if (!threw) { threw = true; throw new Error('boom change listener') } + }) + + expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener') + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked + + off() + ctx.systemPrompt.variable('v', () => 'x') + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' }) + }) + + it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' }) // Listener A appends a section, then delegates. - ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => { + const contexts: AssembleContext[] = [] + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => { + contexts.push(context) assembly.sections.push({ name: 'from-a', order: 100, text: 'a' }) return next() }) // Listener B (registered later, runs after A) sees A's contribution. const seen: string[][] = [] - ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => { + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => { seen.push(assembly.sections.map(s => s.name)) return next() }) - const assembly = await ctx.systemPrompt.assemble() + const passed: AssembleContext = {} + const assembly = await ctx.systemPrompt.assemble(passed) expect(seen).toEqual([['base', 'from-a']]) expect(assembly.sections.map(s => s.name)).toEqual(['base', 'from-a']) + expect(contexts[0]).toBe(passed) // the caller's context reaches listeners }) it('lets a waterfall listener short-circuit by not calling next()', async () => { @@ -100,7 +154,7 @@ describe('SystemPrompt', () => { ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' }) ctx.on('system-prompt/assemble', async () => { - return { sections: [], tools: [] } satisfies PromptAssembly + return { sections: [], tools: [], variables: {} } satisfies PromptAssembly }) const assembly = await ctx.systemPrompt.assemble() @@ -115,40 +169,33 @@ describe('SystemPrompt', () => { const first = await ctx.systemPrompt.assemble() first.sections[0]!.name = 'mutated' + first.sections[0]!.text = 'mutated' first.tools[0]!.description = 'mutated' const firstParameters = first.tools[0]!.parameters as { properties: Record } firstParameters.properties['leak'] = { type: 'string' } const second = await ctx.systemPrompt.assemble() expect(second.sections.map(section => section.name)).toEqual(['base']) + expect(second.sections.map(section => section.text)).toEqual(['base']) expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }]) }) it('filters out empty section text from renderPrompt', () => { - // Direct test of renderPrompt: function returning empty string, and empty static text const result = renderPrompt({ sections: [ - { name: 'empty-fn', order: 0, text: () => '' }, + { name: 'empty', order: 0, text: '' }, { name: 'real', order: 1, text: 'content' }, - { name: 'empty-static', order: 2, text: '' }, ], tools: [], + variables: {}, }) expect(result).toBe('content') }) - it('evaluates dynamic function-text sections at each renderPrompt call', () => { - let counter = 0 - const section: PromptSection = { name: 'dynamic', order: 0, text: () => `call ${++counter}` } - expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 1') - expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 2') - }) - it('emits system-prompt/change when a tool provider is registered and disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - const changes: number = 0 let changeCount = 0 ctx.on('system-prompt/change', () => void changeCount++) @@ -159,7 +206,6 @@ describe('SystemPrompt', () => { dispose() // disposal emits change again expect(changeCount).toBe(2) - void changes // silence unused }) it('cleans up tool providers on fiber dispose', async () => { @@ -196,4 +242,96 @@ describe('SystemPrompt', () => { dispose() expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) }) + + describe('prompt variables', () => { + it('resolves each variable against the assemble context and emits change on register/unregister', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let changeCount = 0 + ctx.on('system-prompt/change', () => void changeCount++) + + const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who) + expect(changeCount).toBe(1) + + expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' }) + // A provider returning undefined records "registered but no value here". + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined }) + + dispose() + expect(changeCount).toBe(2) + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + }) + + it('rejects a duplicate variable name and an unreferenceable name', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.variable('model', () => 'm1') + expect(() => ctx.systemPrompt.variable('model', () => 'm2')) + .toThrow('prompt variable "model" is already registered') + expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x')) + .toThrow('invalid prompt variable name "Not Valid"') + // Neither failed registration leaked. + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' }) + }) + + it('interpolates {{name}} references in section text at render', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You run on {{model}} in {{cwd}}.' }) + ctx.systemPrompt.variable('model', () => 'deepseek-v4') + ctx.systemPrompt.variable('cwd', () => '/work') + + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('You run on deepseek-v4 in /work.') + }) + + it('lets a waterfall listener add or override variables before render', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' }) + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => { + assembly.variables['extra'] = 'from-waterfall' + return next() + }) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('from-waterfall') + }) + + it('throws on a reference to an unregistered variable, listing what exists', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' }) + ctx.systemPrompt.variable('model', () => 'm') + await expect(async () => renderPrompt(await ctx.systemPrompt.assemble())) + .rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model') + }) + + it('names "(none)" when no variables are registered at all', () => { + expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} })) + .toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)') + }) + + it('throws when a referenced variable has no value for this assembly', () => { + expect(() => renderPrompt({ + sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }], + tools: [], + variables: { cwd: undefined }, + })).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")') + }) + + it('throws on a malformed complete reference, e.g. inner spaces', () => { + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text: 'on {{ model }}' }], + tools: [], + variables: { model: 'm' }, + })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"') + }) + + it('leaves a lone {{ without a closing }} verbatim (only complete groups are interpreted)', () => { + const text = renderPrompt({ + sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }], + tools: [], + variables: {}, + }) + expect(text).toBe('shell ${X:-{{fallback} stays') + }) + }) }) diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index b211d6d80e..a9cc4a9806 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -72,7 +72,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.systemPrompt.section({ name: 'tool:read', order: 100, - text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.', + text: 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.', }) ctx.tools.register(defineTool({ diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index cd425e763c..b2ddd9ab2d 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -90,6 +90,8 @@ type ResolvedConfig = Required> & Pick */ class AcpProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. + readonly inheritsParentContext = false constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 02c1811d82..b6d0c10e44 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -62,6 +62,8 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] { */ class ForkProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + // Context contract: a forked child IS seeded with the parent's completed-turn prefix. + readonly inheritsParentContext = true constructor(readonly name: string, private readonly ctx: Context) {} diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 2ea082e20a..e6cf5039a7 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -39,6 +39,8 @@ export const Config: z = z.object({ */ class SpawnProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + // Context contract: a spawned child starts fresh — it never sees the parent conversation. + readonly inheritsParentContext = false constructor(readonly name: string, private readonly ctx: Context) {} diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3b72971dc1..9ccfb249d8 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -28,6 +28,8 @@ Unlike the bash seam (one executor per context, second load throws), **multiple - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. +Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. + ## Run lifecycle `provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb60d5667c..ef76a96e5d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -163,6 +163,16 @@ export interface SubagentProvider { readonly name: string /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities + /** + * The provider's context contract: `true` when a child SEES the parent + * conversation (fork — the child is seeded with the parent's completed-turn + * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, + * not a start-time capability: the service validates nothing against it — + * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool + * wording from it, so a tool bound to a fork provider stops telling the + * model the child "does not see this conversation". + */ + readonly inheritsParentContext: boolean /** * Start a child run. The service has already validated that every requested * start-time capability is supported, so an implementation may assume e.g. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3a8807ad0d..6aa5dfe021 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -22,6 +22,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, /** A scripted provider whose run settles immediately with a fixed result. */ class StubProvider implements SubagentProvider { startCount = 0 + readonly inheritsParentContext = false constructor( readonly name: string, readonly capabilities: SubagentCapabilities = ALL_CAPS, @@ -234,6 +235,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider({ name: 'rej', capabilities: NO_CAPS, + inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), result: Promise.reject(new Error('infra fault')), @@ -267,6 +269,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider({ name: 'unclone', capabilities: NO_CAPS, + inheritsParentContext: false, start: () => ({ id: AgentId('unclone-child'), result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), @@ -296,6 +299,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider({ name: 'rejecter', capabilities: NO_CAPS, + inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), result: Promise.reject(new Error('infra fault')), diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 1bb48f29ff..df00f6f169 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,6 +6,10 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. +## The description states the provider's context contract + +The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, `apply` resolves the provider at LOAD time and **throws if it is not registered yet — list the backend plugin before this one in `cordis.yml`**; a wiring mistake fails loudly at boot instead of shipping a lying description. + | Config key | Meaning | |---|---| | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 05490127ea..766e3711f3 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -11,6 +11,13 @@ * — there is no provider/type parameter in the model-facing schema. The model * sees only `{ description, prompt }`. * + * The tool DESCRIPTION is derived from the bound provider's context contract + * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the + * standalone-prompt wording, an inheriting provider (fork) tells the model the + * child already sees the conversation's completed turns. `apply` therefore + * resolves the provider at load time and throws if it is not registered yet — + * list the backend plugin before this one in `cordis.yml`. + * * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits * `run.result` inside a `try/finally` that always disposes the run, so the * owned child agent/session is torn down on every path (success, error, abort) @@ -92,15 +99,56 @@ function stopReasonError(result: SubagentResult): string | undefined { } } -export function apply(ctx: Context, config: Config): void { - ctx.tools.register(defineTool({ - name: config.toolName ?? 'subagent', +/** + * Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}). + * A fresh child needs a standalone prompt; a forked child already sees the + * conversation's completed turns — telling the model to restate everything + * (or, worse, that the child "does not see this conversation") would be false + * for a fork. Exported for tests. + * @param inherits - the bound provider's context contract. + * @returns the tool `description` and the `prompt` parameter description. + */ +export function providerWording(inherits: boolean): { description: string; promptDescription: string } { + if (inherits) { + return { + description: + 'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all ' + + 'completed turns so far (it does not see the current in-flight turn), returning only its final ' + + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, ' + + 'a review, a continuation — without consuming this conversation\'s context for the work itself. ' + + 'You receive only its final answer, not its intermediate steps.', + promptDescription: + 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them ' + + 'freely and state only what is new.', + } + } + return { description: 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) ' + 'and return its final result. Use this to offload focused, independent work — research, a scoped ' + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent ' + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a ' + 'complete, standalone prompt: it does not see this conversation.', + promptDescription: + 'The complete, self-contained task for the subagent. It does not share this ' + + 'conversation\'s context, so include everything it needs.', + } +} + +export function apply(ctx: Context, config: Config): void { + // Resolve the bound provider NOW: the tool description must state the + // provider's context contract, so the backend plugin must be loaded before + // this one (list it earlier in cordis.yml). Fail loud at load, not with a + // lying description at model time. + const provider = ctx.subagents.getProvider(config.provider) + if (provider === undefined) { + throw new Error( + `subagent provider "${config.provider}" is not registered; load its backend plugin before tool-subagent`) + } + const wording = providerWording(provider.inheritsParentContext) + ctx.tools.register(defineTool({ + name: config.toolName ?? 'subagent', + description: wording.description, parameters: { description: { type: 'string', @@ -110,8 +158,7 @@ export function apply(ctx: Context, config: Config): void { prompt: { type: 'string', required: true, - description: 'The complete, self-contained task for the subagent. It does not share this ' - + 'conversation\'s context, so include everything it needs.', + description: wording.promptDescription, }, }, async execute(args, exec): Promise { diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 2f40cd6f8c..8f00c01366 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -112,6 +112,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'weird', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('weird-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), @@ -137,6 +138,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'capture', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: (request) => { seen = request return { @@ -166,6 +168,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'bare', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: (request) => { seen = request return { @@ -192,14 +195,36 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toContain('requires a calling agent') }) - it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here ' - + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => { - // Bind the tool to a provider name that is not registered: the service throws - // NO_PROVIDER, the registry turns it into an isError result. - const ctx = await setup({ provider: 'does-not-exist' }) - const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('no subagent provider') + it('fails loud AT LOAD when the bound provider is not registered (backend must load first)', async () => { + // The tool description states the provider's context contract, so apply() + // resolves the provider at load time — a missing backend is a wiring error + // surfaced immediately, not a lying description discovered at model time. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await expect(async () => { + await ctx.plugin(tool, { provider: 'does-not-exist' }) + await new Promise(r => setTimeout(r, 20)) + }).rejects.toThrow('is not registered; load its backend plugin before tool-subagent') + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + }) + + it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('does not see this conversation') + const props = (schema.parameters as { properties: Record }).properties + expect(props['prompt']!.description).toContain('include everything it needs') + }) + + it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => { + const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('INHERITS this conversation') + expect(schema.description).not.toContain('does not see this conversation') + const props = (schema.parameters as { properties: Record }).properties + expect(props['prompt']!.description).toContain('completed turns') }) it('disposes the run on the success path (no leaked child)', async () => { @@ -213,6 +238,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), @@ -235,6 +261,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), result: Promise.resolve({ output: [], stopReason: 'error' as const }), @@ -258,6 +285,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) @@ -304,6 +332,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index 305aea93c4..af169ed4c2 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -14,6 +14,7 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `reply` | `mock subagent reply` | The scripted child's final answer text. | | `stopReason` | `completed` | The stop reason `result` settles with. | | `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | +| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. | | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index c1a988fbfe..e2effe5b4b 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -37,12 +37,14 @@ const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: tru */ class MockSubagentProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean constructor( readonly name: string, private readonly config: Config, ) { this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } + this.inheritsParentContext = config.inheritsParentContext ?? false } start(request: SubagentStartRequest): SubagentRun { @@ -88,6 +90,12 @@ export interface Config { stopReason?: SubagentStopReason /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial + /** + * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); + * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool + * wording in consumer tests. + */ + inheritsParentContext?: boolean /** * Structured value surfaced when a request carries an `outputSchema` and the * `outputSchema` capability is on (default: `{ reply }`). @@ -104,6 +112,7 @@ export const Config: z = z.object({ depthLimit: z.boolean(), toolFilter: z.boolean(), }), + inheritsParentContext: z.boolean(), structured: z.any(), }) diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 3403ef2126..a1f35172c5 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -23,7 +23,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | Key | Default | Routed to | |---|---|---| | `model` | (required) | the per-session agent template the bridge creates agents from | -| `systemPrompt` | (required) | the per-session agent's system prompt | +| `systemPrompt` | (required) | the per-session agent's persona template (may reference `{{model}}`/`{{cwd}}`) | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 38596b7fd0..42a856c5ea 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -15,7 +15,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | Key | Default | Meaning | |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | -| `systemPrompt` | — | Per-agent system prompt. | +| `systemPrompt` | — | Per-agent persona template (may reference `{{model}}`/`{{cwd}}`). | The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config. diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index c75fee966a..9e48ba7f75 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -24,7 +24,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | -| `systemPrompt` | (required) | the `main` agent's system prompt | +| `systemPrompt` | (required) | the `main` agent's persona template (may reference `{{model}}`) | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | @@ -54,7 +54,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte name: '@deepseek-ai/dsh-stdio-agent' config: model: deepseek-v4-flash - systemPrompt: 'You are a CLI coding assistant. Your only tools are bash…' + systemPrompt: 'You are a CLI coding assistant powered by the {{model}} model.' ``` Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 2347f6a8a8..924e0aaeb2 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -200,7 +200,7 @@ describe('tool-web registration', () => { it('contributes prompt sections for the enabled tools', async () => { const { fiber, ctx } = await mountTools() const prompt = await ctx.systemPrompt.assemble() - const text = prompt.sections.map(s => (typeof s.text === 'function' ? s.text() : s.text)).join('\n') + const text = prompt.sections.map(s => s.text).join('\n') expect(text).toContain('web_search') expect(text).toContain('web_fetch') await fiber.dispose() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06189862f2..149a786f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)