diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 4264c40107..0000000000 --- a/CONTEXT.md +++ /dev/null @@ -1,37 +0,0 @@ -# DeepSeek Harness - -Ubiquitous language for the harness; grows as terms crystallize. Decisions live in `docs/rfc/` (this repo's decision log), 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 48ca1a8a22..d24b142869 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,7 +88,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. The loop itself registers the `harness:identity` (order −100) and `agent:persona` (order 0) sections and the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` itself owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, from its `persona` config, shared by every agent in the context) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 549d400d0e..8db44c5f1b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,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:258`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,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:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,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:404`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:394`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,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:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,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:356`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,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:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,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:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -109,7 +109,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:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -121,7 +121,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle 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:264`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -133,7 +133,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:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -145,7 +145,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:392`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ## `fs/*` @@ -285,7 +285,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -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) ### `system-prompt/change` — emit @@ -295,7 +295,7 @@ A section, tool provider, or variable provider was registered or unregistered (t 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:38`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:44`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1601d78920..ca2ee64334 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -180,7 +180,7 @@ Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -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. +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. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). ```ts cordis-catalog section(section: PromptSection): () => void @@ -189,7 +189,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:174`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 7f38abe985..61f980ff73 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -303,7 +303,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## Interception decisions diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e27e6fcf28..f6cd631296 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,17 +7,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:258`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:356`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`), [`compact-basic`](../packages/compact/compact-basic) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:274`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:392`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`), [`compact-basic`](../packages/compact/compact-basic) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -29,8 +29,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:81`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:32`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:87`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:66`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | 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 index a47e1bc69f..eafd42cf9e 100644 --- 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 @@ -26,11 +26,11 @@ The assembled system prompt had four defects, all of one family: facts the harne 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; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. 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). +`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). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### 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: harness identity `-100` (the loop's static `harness:identity` section — every agent's prompt opens by stating it is powered by the DeepSeek Harness SDK), persona `0`, tool guidance `100–199`; other negative orders also 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`). +`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and `deployment:persona` at order 0, whose text is the plugin's own `persona` config. The persona is per-DEPLOYMENT, not per-agent: every agent in the context (subagents included) renders the same one, `AgentOptions.systemPrompt` is deleted along with the per-agent forwarding plumbing (the app configs' `systemPrompt` keys become a `persona` key routed to this plugin through `dsh-agent-core`), and the ACP bridge and `dsh-tool-subagent` stop carrying persona configuration entirely. 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: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. ### Tool guidance ownership @@ -42,7 +42,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## 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. +- **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) - **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. diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 3df0e0322a..727b59f4d4 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -6,7 +6,7 @@ Status: implemented (accepted 2026-07-04) Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: -1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. 2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". ## Decision diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index ca85698c36..83ac07ce55 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -38,13 +38,14 @@ 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 powered by the {{model}} model, driven over - the Agent Client Protocol. Your working directory is {{cwd}}. + # The persona: identity + behavior only, nothing about transports or + # tooling — 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). + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 1980c10aa6..0439262e33 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -46,11 +46,12 @@ resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' 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 powered by the {{model}} model. + # The persona: identity + behavior only, nothing about transports or + # tooling — 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. + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 68bca5cdfa..ce716bdb2c 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -53,11 +53,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test const before = spawnSync('node', ['add.test.js'], { cwd: workdir }) expect(before.status).not.toBe(0) - ctx = await codingHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 2b8f278be3..cb7ce43811 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -53,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // budget even though those blocks are stripped before the checkpoint is // stored. ctx = await codingHarness(workdir, { + persona: SYSTEM_PROMPT, compact: { contextWindow: 2400, thresholdRatio: 0.5, @@ -63,10 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: './.sessions', }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 2b70d6f339..095d2a42a1 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -20,11 +20,8 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => { it('runs a bash command on request and reports its output', async () => { - ctx = await codingHarness(process.cwd()) - const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 7ce24913cf..dd0bc42a1b 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -33,6 +33,11 @@ export const TODO_SYSTEM_PROMPT = 'You are a coding agent. For multi-step work, /** Options for {@link codingHarness}. */ export interface CodingHarnessOptions { + /** + * Deployment persona for the tree (the system-prompt plugin's `persona` + * config — per-context, not per-agent). Omitted ⇒ no persona section. + */ + persona?: string /** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */ persistenceRoot?: string /** @@ -47,7 +52,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 4be11ed3ea..fc216a9848 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -38,11 +38,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 1: a fresh agent on a KNOWN session id learns a secret, then we // dispose the whole context (simulating process exit) so only the JSONL // log on disk survives. - ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = ctx.agents.create({ agentId: AgentId('resume-1'), sessionId: SESSION_ID, - agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, + agentOptions: { model: 'deepseek-v4-flash' }, }).agent as ReactLoopAgent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) @@ -52,11 +52,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 2: a brand-new context over the SAME root resumes the persisted // session. The loaded event log seeds the live session, so the model sees // run 1's exchange as conversation history. - ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, - agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, + agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index 33cac531cf..b100091a0f 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -18,11 +18,8 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => { it('appends a todo/write event with the model-produced task list', async () => { - ctx = await codingHarness(process.cwd()) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { - model: 'deepseek-v4-flash', - systemPrompt: TODO_SYSTEM_PROMPT, - }) + ctx = await codingHarness(process.cwd(), { persona: TODO_SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 9eef3d1a1b..b66c5e8163 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -33,6 +33,6 @@ name: '@deepseek-ai/dsh-stdio-agent' config: model: mock-echo - systemPrompt: 'You are echo-agent, a demo agent.' + persona: 'You are echo-agent, a demo agent.' welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' persistenceRoot: './.sessions' diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index f6a985f594..7d4b34f74f 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -276,10 +276,11 @@ 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']) + expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) + // Only the system-prompt plugin's own built-in sections remain. + expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona']) }) it('tools depend on the executor: no registration without ctx.bash', async () => { diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 022ccba4f4..353e68332d 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -18,6 +18,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas @deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) + (dsh-system-prompt gets the forwarded `persona`) ``` ## What it deliberately leaves OUTSIDE the bundle @@ -34,10 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// Config === AgentLoop.Config — the `agents` list, default []. +// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]), +// so validation and defaulting can never drift from the owners'. ``` -The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index a70ee30e71..5b1eed413a 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -44,5 +44,8 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" } } diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index ad3f5d8c46..0831ae929e 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -44,9 +44,10 @@ import type { Context } from 'cordis' import Timer from '@cordisjs/plugin-timer' +import z from 'schemastery' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import * as invariants from '@deepseek-ai/dsh-invariants' @@ -56,33 +57,45 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen export const name = 'agent-core' /** - * Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]` - * — an app that pre-creates no agents (the ACP bridge creates them on demand at - * `session/new`) simply omits it; an app that needs a pre-created `main` (the - * stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and - * the forwarded shape can never drift. + * Bundle config: each field forwarded verbatim to the child that owns it — + * `agents` to the agent loop (an app that pre-creates no agents, like the ACP + * bridge, simply omits it), `persona` to the system-prompt plugin (the + * deployment's persona section). Both are optional INPUT here because each + * owner's schema supplies the default (`[]` / `''`); the schema is the + * INTERSECTION of the owners' own schemas, so validation and defaulting can + * never drift from them. */ -export type Config = AgentLoopConfig +export interface Config { + /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ + agents?: AgentLoopConfig['agents'] + /** The deployment persona (see dsh-system-prompt's `Config`). */ + persona?: SystemPromptConfig['persona'] +} -/** Forward the loop's own schema so validation + defaulting stay identical. */ -export const Config = AgentLoop.Config +/** Intersect the owners' schemas so validation + defaulting stay identical. */ +export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z /** * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; - * `agent-loop` receives the forwarded `agents` list. Load order is irrelevant - * (cordis pends each fiber on its `inject` until the services it needs exist), - * but the listing mirrors the dependency layering for readability: the LLM - * vocabulary and core registries first, then the dev tripwire and the bash tool - * consumer, then the loop that drives them. + * `agent-loop` receives the forwarded `agents` list and `system-prompt` the + * forwarded `persona`. Load order is irrelevant (cordis pends each fiber on + * its `inject` until the services it needs exist), but the listing mirrors the + * dependency layering for readability: the LLM vocabulary and core registries + * first, then the dev tripwire and the bash tool consumer, then the loop that + * drives them. */ export function apply(ctx: Context, config: Config): void { ctx.plugin(Timer) ctx.plugin(LlmService) ctx.plugin(SessionStore) - ctx.plugin(SystemPrompt) + // The forwarded fields are validated + defaulted by this bundle's intersected + // schema before apply runs, so the ?? fallbacks only narrow the + // optional-input TYPES — they mirror the owners' schema defaults, never + // introduce different ones. + ctx.plugin(SystemPrompt, { persona: config.persona ?? '' }) ctx.plugin(ToolRegistry) ctx.plugin(AgentRegistry) ctx.plugin(invariants) ctx.plugin(toolBash) - ctx.plugin(AgentLoop, { agents: config.agents }) + ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) } diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 67f5d88532..4a4c5587ed 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -43,11 +43,27 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) - it('forwards a pre-created agent to the loop', async () => { + it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }], + agents: [{ id: AgentId('main'), model: 'mock' }], + persona: 'You are main.', }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const assembly = await ctx.get('systemPrompt')!.assemble() + expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') + await ctx.fiber.dispose() + }) + + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { + // ctx.plugin validates + defaults the bundle config first; a direct apply + // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. + const ctx = new Context() + agentCore.apply(ctx, {}) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('agents')?.list()).toHaveLength(0) + const assembly = await ctx.get('systemPrompt')!.assemble() + expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('') await ctx.fiber.dispose() }) diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json index 83bf06c586..91e5ec894e 100644 --- a/packages/core/agent-core/tsconfig.json +++ b/packages/core/agent-core/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../../vendor/timer" }, diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 25480403bd..e169fce45a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -28,12 +28,11 @@ interface Config { agents: Array<{ id: string // required model?: string - systemPrompt?: string // the agent's persona TEMPLATE (may reference {{model}}/{{cwd}}) }> } ``` -Agents listed in config are auto-created at startup. The plugin also registers the harness-owned prompt pieces on `ctx.systemPrompt`: the `harness:identity` section (order −100 — every agent's prompt opens by stating it is powered by the DeepSeek Harness SDK), the `agent:persona` section (order 0 — `AgentOptions.systemPrompt` renders after it, before all tool guidance), and the built-in `model`/`cwd` prompt variables, resolved per step from the `assemble({ agent })` context. +Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Classes diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ed7243caf8..9eaa61624a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -72,7 +72,6 @@ export class AgentLoop extends Service implements AgentFactory { agents: z.array(z.object({ id: z.string().required(), model: z.string(), - systemPrompt: z.string(), resumeSessionId: z.string(), })).default([]), }) as unknown as z @@ -82,25 +81,14 @@ 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 prompt pieces the harness itself owns, registered once. The - // harness-identity section states what every agent on this loop IS, - // ahead of everything (order −100 — before the deployment's persona); - // the persona is the order-0 section resolved per assembly from the - // AssembleContext the loop passes (loop.ts assembles with `{ agent }` - // each step); `{{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: 'harness:identity', - order: -100, - text: 'You are an AI agent powered by the DeepSeek Harness SDK.', - }) - ctx.systemPrompt.section({ - name: 'agent:persona', - order: 0, - text: context => context.agent?.options.systemPrompt ?? '', - }) + // The prompt variables the shipped loop provides, registered once. The + // sections themselves (`harness:identity`, `deployment:persona`) belong to + // dsh-system-prompt — they must survive a swapped loop plugin — but + // `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives: + // it assembles with `{ agent }` each step (loop.ts), and the variables + // project the agent's configured model and its session workspace from that + // context. A provider returns undefined when the fact is absent + // (renderPrompt then rejects a persona that claims it — fail loud). 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) { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 8cf5bd81f8..a645fb3553 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -35,7 +35,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent @@ -52,7 +52,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent @@ -92,7 +92,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -120,7 +120,7 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 1a0525c0b2..7de441a928 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -8,11 +8,11 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) @@ -143,7 +143,9 @@ describe('agent loop', () => { it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => { const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) + // 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 ctx = await harness(adapter, 'You are a test agent on {{model}}.') ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) ctx.tools.register(defineTool({ name: 'noop', @@ -153,9 +155,7 @@ describe('agent loop', () => { return [] }, })) - // 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}}.' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -167,12 +167,12 @@ describe('agent loop', () => { 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 ctx = await harness(adapter, 'Working in {{cwd}}.') const handle = ctx.agents.create({ agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, - agentOptions: { model: 'mock', systemPrompt: 'Working in {{cwd}}.' }, + agentOptions: { model: 'mock' }, }) const agent = handle.agent as ReactLoopAgent @@ -189,10 +189,10 @@ describe('agent loop', () => { // report idle status): a rescue listener supplies the variable and the // follow-up prompt reaches the model. const adapter = new MockAdapter([textResponse('ok after rescue')]) - const ctx = await harness(adapter) + const ctx = await harness(adapter, 'In {{cwd}}.') 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}}.' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -218,6 +218,22 @@ describe('agent loop', () => { expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) + it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { + // The documented escape valve: a deployment that must drop the harness + // openers short-circuits the assemble waterfall; the request then carries + // NO system field at all (not an empty string). + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) + const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect('system' in adapter.requests[0]!).toBe(false) + }) + it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) @@ -853,7 +869,7 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }], + agents: [{ id: AgentId('config-agent'), model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index ec9ba796b9..096555f925 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -49,7 +49,7 @@ export interface CreateAgentOptions { * for a fresh (spawn) child. */ seed?: SessionEvent[] - /** Per-agent options (model, system prompt). */ + /** Per-agent options (model, …). */ agentOptions?: AgentOptions } @@ -62,7 +62,7 @@ export interface ResumeAgentOptions { agentId: AgentId /** The persisted session id to load and resume on. */ resumeSessionId: SessionId - /** Per-agent options (model, system prompt). */ + /** Per-agent options (model, …). */ agentOptions?: AgentOptions } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3823b0db61..1b4af61d9a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -60,9 +60,8 @@ 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` → + * per-step `assemble({ agent })`; variable providers project per-agent + * facts from it (`options.model` → `{{model}}`, `session.header.cwd` → * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) * has no agent — providers must tolerate its absence. */ @@ -71,23 +70,14 @@ declare module '@deepseek-ai/dsh-system-prompt' { } /** - * Options an agent is created with. + * Options an agent is created with. The persona is NOT here — it is the + * deployment's `persona` config on the dsh-system-prompt plugin, shared by + * every agent in the context. * Merge-extensible: plugins declare extra fields via declaration merging. */ export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string - /** - * 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. Template, not free-form text: every complete `{{…}}` group IS - * interpreted, strictly — an unknown or malformed reference fails the turn - * loudly — and there is no escape syntax for literal `{{…}}` prose yet (a - * deliberate deferral; see the prompt-variables RFC). - */ - systemPrompt?: string } export interface SendOptions { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 5626d06a1c..be705de03e 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,12 @@ # dsh-system-prompt -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. +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. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) @@ -21,7 +27,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Key types - `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: `-100` is the harness identity, `0` the per-agent persona (both registered by the agent loop), tool guidance uses `100–199`; other negative orders also render before the persona. +- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also 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 (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -29,14 +35,14 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse ### Extension points -- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); the agent loop owns `harness:identity` and `agent:persona`. +- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment: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 per caller (dynamic tool filtering, extra variables). ### What is NOT here -- Any hardcoded prompt text — every section comes from plugins, every deployment-authored word from config. +- Any deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.) - 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/package.json b/packages/core/system-prompt/package.json index 672f7a03ef..d97a7b8538 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -25,6 +25,9 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.6" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 4da727f4e5..c970c66b30 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -4,10 +4,16 @@ * collates them through a waterfall that runs once per step, and * `renderPrompt` interpolates `{{variable}}` references into the final text. * + * The harness-owned prompt openers live here too: this plugin registers the + * static `harness:identity` section (order −100) and the deployment's + * `deployment:persona` section (order 0, from its `persona` config), so they + * exist for every agent regardless of which loop plugin drives it. + * * @module @deepseek-ai/dsh-system-prompt */ import { Context, Service } from 'cordis' +import z from 'schemastery' import type { ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { @@ -55,7 +61,7 @@ export interface PromptSection { name: string /** * Sections are concatenated in ascending order. Convention: `-100` is the - * harness identity, `0` the per-agent persona, tool guidance uses 100–199; + * harness identity, `0` the deployment persona, tool guidance uses 100–199; * other negative orders also render before the persona. */ order: number @@ -104,6 +110,22 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** A complete `{{...}}` reference group at the scan position (validated after). */ const GROUP_AT = /^\{\{([^{}]*)\}\}/ +export interface Config { + /** + * The deployment's persona — the ONE deployment-authored fragment of the + * system prompt, rendered as the order-0 `deployment:persona` section + * (after the harness identity, before all tool guidance). Every agent in + * the context shares it, subagents included. Template, not free-form text: + * every complete `{{…}}` group is interpreted strictly against the + * registered prompt variables (the shipped agent loop registers `{{model}}` + * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose + * yet (a deliberate deferral; see the prompt-variables RFC). Defaults to + * `''` — the empty section is dropped at render, so a persona-less + * deployment opens with the harness identity alone. + */ + persona?: string +} + /** * Renders the text part of an assembly: interpolates `{{variable}}` * references in each section from `assembly.variables`, drops empty sections, @@ -169,15 +191,39 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ + persona: z.string().default(''), + }) + private sections: PromptSection[] = [] private toolProviders: (() => ToolSchema[])[] = [] private variableProviders = new Map string | undefined>() - constructor(ctx: Context) { + constructor(ctx: Context, public config: Config) { super(ctx, 'systemPrompt') + // The harness-owned openers. They live HERE (not on the loop plugin) so a + // deployment that swaps in a different loop keeps them: the identity is a + // harness fact stated ahead of everything, and the persona is the + // deployment's config, one section of the full prompt, never the whole. + // An empty persona still RESERVES the section name (one owner — a plugin + // re-registering it throws); renderPrompt drops the empty text. + this.section({ + name: 'harness:identity', + order: -100, + text: 'You are an AI agent powered by the DeepSeek Harness SDK.', + }) + this.section({ + name: 'deployment:persona', + order: 0, + // The schema already defaulted an omitted persona to ''; the ?? only + // narrows the optional-input TYPE, it never supplies a different value. + text: config.persona ?? '', + }) } /** diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 4aef36e9d0..c0bd8be6d2 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -2,22 +2,64 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +/** + * Every assembly carries the plugin's own built-ins — `harness:identity` + * (order −100) and `deployment:persona` (order 0, from config). Tests about + * registry MECHANICS strip them with {@link contributed} to stay focused on + * their own sections; the built-ins' behavior is pinned by its own describe. + */ +const BUILT_IN = ['harness:identity', 'deployment:persona'] +const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.' +function contributed(assembly: PromptAssembly): PromptAssembly['sections'] { + return assembly.sections.filter(section => !BUILT_IN.includes(section.name)) +} + describe('SystemPrompt', () => { + describe('built-in sections', () => { + it('registers the harness identity and the configured deployment persona', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(s => [s.name, s.order])).toEqual([ + ['harness:identity', -100], + ['deployment:persona', 0], + ]) + expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`) + // The names are reserved by the plugin — one owner per section. + expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' })) + .toThrow('prompt section "deployment:persona" is already registered') + }) + + it('renders no persona section for a persona-less deployment (empty default)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY) + }) + + it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { + // ctx.plugin validates + defaults the config first; a direct construction + // skips the schema, so the ctor's `?? ''` narrowing is what fires. + const ctx = new Context() + const service = new SystemPrompt(ctx, {}) + expect(renderPrompt(await service.assemble())).toBe(IDENTITY) + }) + }) + it('assembles sections in order with context-resolved text and collected tools', async () => { const ctx = new Context() - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' }) - ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Harness SDK.' }) ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' }) ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' }) ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }]) 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 Harness SDK.', 'Be precise.', 'cwd: /tmp']) + expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd']) + expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', '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 Harness SDK.\n\nBe precise.\n\ncwd: /tmp') + expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`) }) it('resolves section text providers against the assemble context, at each assemble call', async () => { @@ -32,8 +74,8 @@ describe('SystemPrompt', () => { 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') + expect(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice') + expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody') }) it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => { @@ -47,11 +89,13 @@ describe('SystemPrompt', () => { }, { inject: ['systemPrompt'] })) const before = await ctx.systemPrompt.assemble() - expect(before.sections).toHaveLength(1) + expect(contributed(before)).toHaveLength(1) expect(before.variables).toEqual({ scoped_var: 'v' }) await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections).toHaveLength(0) + expect(contributed(assembly)).toHaveLength(0) + // The built-ins belong to the service fiber, so they survive the plugin's disposal. + expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN) expect(assembly.tools).toHaveLength(0) expect(assembly.variables).toEqual({}) }) @@ -64,7 +108,7 @@ describe('SystemPrompt', () => { .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']) + expect(contributed(assembly).map(s => s.text)).toEqual(['first']) }) it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => { @@ -80,12 +124,12 @@ describe('SystemPrompt', () => { }) expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener') - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked // Subsequent listener-free register contributes exactly once. off() ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' }) - expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p']) + expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p']) }) it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => { @@ -143,8 +187,8 @@ describe('SystemPrompt', () => { 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(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']]) + expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a']) expect(contexts[0]).toBe(passed) // the caller's context reaches listeners }) @@ -175,8 +219,8 @@ describe('SystemPrompt', () => { 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.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base']) + expect(second.sections[0]!.text).toBe(IDENTITY) expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }]) }) @@ -226,10 +270,10 @@ describe('SystemPrompt', () => { await ctx.plugin(SystemPrompt) const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' }) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1) + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1) dispose() - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) }) it('removes tool provider when returned disposer is called directly', async () => { @@ -274,14 +318,13 @@ describe('SystemPrompt', () => { expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' }) }) - it('interpolates {{name}} references in section text at render', async () => { + it('interpolates {{name}} references in section text at render — the persona included', async () => { const ctx = new Context() - await ctx.plugin(SystemPrompt) - ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You run on {{model}} in {{cwd}}.' }) + await ctx.plugin(SystemPrompt, { persona: '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.') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`) }) it('lets a waterfall listener add or override variables before render', async () => { @@ -292,7 +335,7 @@ describe('SystemPrompt', () => { assembly.variables['extra'] = 'from-waterfall' return next() }) - expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('from-waterfall') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nfrom-waterfall`) }) it('throws on a reference to an unregistered variable, listing what exists', async () => { @@ -360,7 +403,7 @@ describe('SystemPrompt', () => { await ctx.plugin(SystemPrompt) ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' }) ctx.systemPrompt.variable('constructor', () => 'own-value') - expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('own-value') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`) }) it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => { diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 9f687793d7..e9de391ba1 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../llm/llm" } diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 5e13e229fb..da5412530d 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -32,10 +32,10 @@ const SYSTEM = 'You are a coding assistant. Use the write tool to create files, describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => { it('creates, reads, then edits a file — verified on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-')) - ctx = await fsHarness(workdir) + ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM }) + const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' @@ -63,12 +63,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => workdir = configDir const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-')) try { - ctx = await fsHarness(configDir) + ctx = await fsHarness(configDir, SYSTEM) const handle = ctx.agents.create({ agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, - agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM }, + agentOptions: { model: 'deepseek-v4-flash' }, }) handle.agent.send([{ type: 'text', text: 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 0a492c509e..61c163c28b 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -18,13 +18,14 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' * * `fsCwd` is the local backend's default base; a per-session cwd (set via a * session header) overrides it, but this harness creates agents without a - * session cwd, so the provider default IS the workspace. + * session cwd, so the provider default IS the workspace. `persona` is the + * deployment persona (the system-prompt plugin's per-context config). */ -export async function fsHarness(fsCwd: string): Promise { +export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ac99d176d4..53f912cce2 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -133,10 +133,11 @@ describe('registration', () => { // withdraw both, not just the schemas. expect(ctx.tools.schemas()).toHaveLength(3) const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() - expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write']) + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) + // Only the system-prompt plugin's own built-in sections remain. + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity']) }) }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 4b8d2d4c99..1107926aa2 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -107,9 +107,9 @@ export function startInProcessRun( const seedLength = options.seed?.length ?? 0 const parentHeader = request.parent.session.header // Inherit the parent's model by default (a child with no model cannot run); - // an explicit `request.agentOptions.model` overrides it. The parent's - // systemPrompt is NOT inherited — a fresh child is a clean specialist unless - // the caller supplies one. + // an explicit `request.agentOptions.model` overrides it. The persona needs + // no inheritance: the deployment persona is a context-wide prompt section, + // so parent and child render the same one. const agentOptions: AgentOptions = { ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, ...request.agentOptions, diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index ff551cfc3f..b3e9d4ec24 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -23,7 +23,10 @@ export async function spawnHarness(workdir: string): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + // The deployment persona is context-wide (parent AND spawned children + // render it), so it stays neutral for both roles; the delegation nudge + // lives in the e2e's user prompt and the subagent tool's own description. + await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 8179027976..daa032199e 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -29,11 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { - model: 'deepseek-v4-flash', - systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — ' - + 'give it a complete, standalone instruction. Report only when done.', - }) + const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index fd04fba3b2..6fe26d3083 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -14,7 +14,7 @@ The tool description and the `prompt` parameter description are DERIVED from the |---|---| | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | | `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | -| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | +| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 383bef0f0d..f48ef4345e 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -53,8 +53,10 @@ export interface Config { */ toolName?: string /** - * Default per-child agent options (model, system prompt) applied to every - * spawned child. Omitted fields fall back to the child loop's own defaults. + * Default per-child agent options (model) applied to every spawned child. + * Omitted fields fall back to the child loop's own defaults. There is no + * per-child persona: the deployment persona (the system-prompt plugin's + * `persona` config) is a context-wide section every agent shares. */ agentOptions?: AgentOptions } @@ -64,7 +66,6 @@ export const Config: z = z.object({ toolName: z.string().default('subagent'), agentOptions: z.object({ model: z.string(), - systemPrompt: z.string(), }), }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 8799eba06b..611069ef76 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -149,10 +149,10 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' } }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) + expect(seen?.agentOptions).toEqual({ model: 'child-model' }) }) it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index a1f35172c5..4a114619a6 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 persona template (may reference `{{model}}`/`{{cwd}}`) | +| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index c505e9bf41..3bd498dc63 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -39,35 +39,38 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' export const name = 'acp-agent' /** - * App config: the swappable per-deployment values. `model`/`systemPrompt` - * configure the agent template the ACP bridge creates each session's agent from - * (NOT a pre-created agent — ACP creates agents at `session/new`); + * App config: the swappable per-deployment values. `model` configures the + * agent template the ACP bridge creates each session's agent from (NOT a + * pre-created agent — ACP creates agents at `session/new`); `persona` is the + * deployment persona (forwarded to the system-prompt plugin); * `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ model: string - /** Per-agent system prompt for ACP-created agents. */ - systemPrompt: string + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string } export const Config: z = z.object({ model: z.string().required(), - systemPrompt: z.string().required(), + persona: z.string(), persistenceRoot: z.string().default('./.sessions'), }) /** * Compose the spine with the ACP front door. The agent-core bundle pre-creates - * NO agents (its `agents` list defaults to `[]`); the JSONL backend persists - * under `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates - * one agent per `session/new` from `model`/`systemPrompt`. No logger, no `hmr` — - * stdout stays pure. + * NO agents (its `agents` list defaults to `[]`) and carries the deployment + * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP + * bridge owns stdout for JSON-RPC and creates one agent per `session/new` + * from `model`. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { - ctx.plugin(agentCore) + ctx.plugin(agentCore, { + ...config.persona !== undefined ? { persona: config.persona } : {}, + }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) - ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt }) + ctx.plugin(acp, { model: config.model }) } diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 7a02837fca..87cf670107 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -24,7 +24,7 @@ async function mount(config: acpAgent.Config): Promise { describe('dsh-acp-agent composition', () => { it('brings up the spine + persistence + the ACP bridge', async () => { - const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -40,7 +40,8 @@ describe('dsh-acp-agent composition', () => { // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. const ctx = new Context() - acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + // No persona: covers the omitted-persona forwarding branch too. + acpAgent.apply(ctx, { model: 'mock' }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('sessionPersistence')).toBeDefined() await ctx.fiber.dispose() diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index b9f75d6a89..60363566b7 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -15,7 +15,8 @@ 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 persona template (may reference `{{model}}`/`{{cwd}}`). | + +(No persona key: the deployment persona is `dsh-system-prompt`'s own `persona` config — a context-wide section, so ACP-created agents render it without the bridge carrying prompt text.) 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/acp/src/index.ts b/packages/ui/acp/src/index.ts index 717e57a1f5..a819e607d0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -115,8 +115,6 @@ function sameWorkspaceCwd(left: string, right: string): boolean { export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** Per-agent system prompt. */ - systemPrompt?: string /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -129,7 +127,6 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ model: Schema.string(), - systemPrompt: Schema.string(), }) /** @@ -705,10 +702,9 @@ export function apply(ctx: Context, config: AcpConfig): void { * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. */ -export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?: string } { +export function agentOptions(config: AcpConfig): { model?: string } { return { ...config.model !== undefined ? { model: config.model } : {}, - ...config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}, } } diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 6f8edd341f..1e7e9ae511 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -148,15 +148,15 @@ describe('acp bridge', () => { await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined() }) - it('honors systemPrompt config', async () => { + it('renders the deployment persona into ACP-created agents\' requests', async () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')], - config: { systemPrompt: 'be terse' }, + persona: 'be terse', }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // Create + prompt so the systemPrompt config flows through agentOptions and - // reaches the model request. + // Create + prompt so the system-prompt plugin's persona section reaches + // the model request of an agent the BRIDGE created (session/new). const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] }) expect(harness.adapter.requests[0]?.system).toContain('be terse') diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 01a5d30abc..39d77fe624 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -153,6 +153,8 @@ export interface BridgeHarness { export async function makeBridgeHarness(options: { script?: (StreamChunk[] | 'hang')[] config?: Partial + /** Deployment persona for the tree (the system-prompt plugin's config). */ + persona?: string storageDir: string /** * Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of @@ -183,7 +185,7 @@ export async function makeBridgeHarness(options: { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 311a853acd..cb3eab3545 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -818,7 +818,5 @@ describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) - expect(agentOptions({ systemPrompt: 'sp' })).toEqual({ systemPrompt: 'sp' }) - expect(agentOptions({ model: 'm', systemPrompt: 'sp' })).toEqual({ model: 'm', systemPrompt: 'sp' }) }) }) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 9e48ba7f75..fd608b7888 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | @@ -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 persona template (may reference `{{model}}`) | +| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | @@ -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 powered by the {{model}} model.' + persona: 'You are a 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/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index c576d6aaba..fe63e02643 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -51,15 +51,16 @@ export const name = 'stdio-agent' /** * App config: the swappable per-demo values, each routed to where the app wires - * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main` - * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); + * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is + * the deployment persona (forwarded to the system-prompt plugin); * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner. */ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string - /** System prompt for the `main` agent. */ - systemPrompt: string + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -74,7 +75,7 @@ export interface Config { export const Config: z = z.object({ model: z.string().required(), - systemPrompt: z.string().required(), + persona: z.string(), persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), resumeSessionId: z.string(), @@ -83,17 +84,17 @@ export const Config: z = z.object({ /** * Compose the spine with the stdio front door. The console logger comes first * (infra), then the agent-core bundle pre-creating the `main` agent from this - * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then - * the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf - * concern (see the module doc), so it is not mounted here. + * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL + * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is + * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { + ...config.persona !== undefined ? { persona: config.persona } : {}, agents: [{ id: AgentId('main'), model: config.model, - systemPrompt: config.systemPrompt, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], }) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 5c22fdb984..09fbf8987d 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -8,8 +8,9 @@ import * as stdioAgent from '../src/index.ts' * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it * composes the console logger, the agent-core spine (pre-creating the `main` * agent from the app config), the JSONL backend, and the readline UI in one - * `ctx.plugin`. The forwarded `model`/`systemPrompt` reach the pre-created - * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends. + * `ctx.plugin`. The forwarded `model` reaches the pre-created agent and + * `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/ + * `resumeSessionId` route to their backends. * * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev * plugin the in-process tier cannot import); the keyless echo smoke in @@ -30,7 +31,7 @@ async function mount(config: stdioAgent.Config): Promise { describe('dsh-stdio-agent app', () => { it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' }) // The spine services (brought up by the agent-core bundle) are all present. expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() @@ -46,7 +47,8 @@ describe('dsh-stdio-agent app', () => { // apply()'s last two lines are the ones that fire — covering a // schema-bypassing direct-mount caller. const ctx = new Context() - stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + // No persona: covers the omitted-persona forwarding branch too. + stdioAgent.apply(ctx, { model: 'mock' }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() @@ -59,7 +61,7 @@ describe('dsh-stdio-agent app', () => { // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ model: 'mock', - systemPrompt: 'hi', + persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', resumeSessionId: 'no-such-session', }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ec5a17017..d5fb68c746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,10 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/core/agent-core: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@cordisjs/plugin-timer': specifier: workspace:^ @@ -267,6 +271,10 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/core/system-prompt: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-llm': specifier: workspace:^