浏览代码

review: the persona becomes the system-prompt plugin's deployment config

Review round 2 (tianyicui inline comments):

- dsh-system-prompt itself registers the harness:identity (-100) and
  deployment:persona (0) sections — they must survive a swapped loop
  plugin, so they leave dsh-agent-loop; the persona text is the plugin's
  own validated 'persona' config. The model/cwd variables STAY on the
  loop: runtime facts of the agents it drives.
- AgentOptions.systemPrompt is deleted with all its forwarding plumbing:
  the app configs' systemPrompt keys become 'persona' routed through
  dsh-agent-core (schema = z.intersect of the owners'), the ACP bridge
  and tool-subagent stop carrying persona configuration, and subagent
  children now render the deployment persona like every other agent.
- Example personas drop transport/interface trivia (ACP, CLI) — facts
  irrelevant to the model.
- Root CONTEXT.md removed (not idiomatic); its persona definition was
  wrong under the new ownership anyway.
- Docs, READMEs, the prompt-variables RFC, and generated catalogs
  updated; new loop test pins the assemble-waterfall escape valve
  (an emptied assembly sends NO system field).
Tianyi Cui 3 月之前
父节点
当前提交
3f83a4ee96
共有 55 个文件被更改,包括 389 次插入 和 284 次删除
  1. 0 37
      CONTEXT.md
  2. 1 1
      docs/architecture.md
  3. 13 13
      docs/cordis-catalog/events.md
  4. 2 2
      docs/cordis-catalog/services.md
  5. 1 1
      docs/core-data-structures/core.md
  6. 13 13
      docs/event-producer-consumer.md
  7. 3 3
      docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
  8. 1 1
      docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md
  9. 8 7
      examples/acp-agent/cordis.yml
  10. 6 5
      examples/coding-agent/cordis.yml
  11. 2 5
      examples/coding-agent/tests/coding-task.e2e.ts
  12. 2 4
      examples/coding-agent/tests/compaction.e2e.ts
  13. 2 5
      examples/coding-agent/tests/full-loop.e2e.ts
  14. 6 1
      examples/coding-agent/tests/harness.ts
  15. 4 4
      examples/coding-agent/tests/resume.e2e.ts
  16. 2 5
      examples/coding-agent/tests/todo-write.e2e.ts
  17. 1 1
      examples/echo-agent/cordis.yml
  18. 3 2
      packages/bash/tool-bash/tests/tools.spec.ts
  19. 4 2
      packages/core/agent-core/README.md
  20. 3 0
      packages/core/agent-core/package.json
  21. 29 16
      packages/core/agent-core/src/index.ts
  22. 18 2
      packages/core/agent-core/tests/agent-core.spec.ts
  23. 3 0
      packages/core/agent-core/tsconfig.json
  24. 1 2
      packages/core/agent-loop/README.md
  25. 8 20
      packages/core/agent-loop/src/index.ts
  26. 4 4
      packages/core/agent-loop/tests/config-session-id.spec.ts
  27. 27 11
      packages/core/agent-loop/tests/loop.spec.ts
  28. 2 2
      packages/core/agent/src/index.ts
  29. 5 15
      packages/core/agent/src/types.ts
  30. 10 4
      packages/core/system-prompt/README.md
  31. 3 0
      packages/core/system-prompt/package.json
  32. 49 3
      packages/core/system-prompt/src/index.ts
  33. 67 24
      packages/core/system-prompt/tests/system-prompt.spec.ts
  34. 3 0
      packages/core/system-prompt/tsconfig.json
  35. 4 4
      packages/fs/tool-fs/tests/fs-tools.e2e.ts
  36. 4 3
      packages/fs/tool-fs/tests/harness.ts
  37. 3 2
      packages/fs/tool-fs/tests/tools.spec.ts
  38. 3 3
      packages/subagent/subagent-inprocess/src/index.ts
  39. 4 1
      packages/subagent/subagent-spawn/tests/harness.ts
  40. 1 5
      packages/subagent/subagent-spawn/tests/spawn.e2e.ts
  41. 1 1
      packages/subagent/tool-subagent/README.md
  42. 4 3
      packages/subagent/tool-subagent/src/index.ts
  43. 2 2
      packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
  44. 1 1
      packages/ui/acp-agent/README.md
  45. 15 12
      packages/ui/acp-agent/src/index.ts
  46. 3 2
      packages/ui/acp-agent/tests/acp-agent.spec.ts
  47. 2 1
      packages/ui/acp/README.md
  48. 1 5
      packages/ui/acp/src/index.ts
  49. 4 4
      packages/ui/acp/tests/bridge.spec.ts
  50. 3 1
      packages/ui/acp/tests/harness.ts
  51. 0 2
      packages/ui/acp/tests/stream-update.spec.ts
  52. 3 3
      packages/ui/stdio-agent/README.md
  53. 10 9
      packages/ui/stdio-agent/src/index.ts
  54. 7 5
      packages/ui/stdio-agent/tests/stdio-agent.spec.ts
  55. 8 0
      pnpm-lock.yaml

+ 0 - 37
CONTEXT.md

@@ -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.
-

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

+ 13 - 13
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<PromptAssembly>): Promise<PromptAssembly>
 ```
 
-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/*`
 

+ 2 - 2
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<PromptAssembly>
 ```
 
-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`
 

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

+ 13 - 13
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) |

+ 3 - 3
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.

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

+ 8 - 7
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.

+ 6 - 5
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.

+ 2 - 5
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',

+ 2 - 4
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',

+ 2 - 5
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)

+ 6 - 1
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: [] })

+ 4 - 4
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.

+ 2 - 5
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 '

+ 1 - 1
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 <text>" triggers the tool).'
     persistenceRoot: './.sessions'

+ 3 - 2
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 () => {

+ 4 - 2
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
 

+ 3 - 0
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"
   }
 }

+ 29 - 16
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<Config>
 
 /**
  * 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 ?? [] })
 }

+ 18 - 2
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()
   })
 

+ 3 - 0
packages/core/agent-core/tsconfig.json

@@ -11,6 +11,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../../vendor/timer"
     },

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

+ 8 - 20
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<Config>
@@ -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) {

+ 4 - 4
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 })

+ 27 - 11
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)
 

+ 2 - 2
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
 }
 

+ 5 - 15
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 {

+ 10 - 4
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<string, string | undefined> }`. 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).

+ 3 - 0
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"

+ 49 - 3
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<string, string
 /**
  * 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.
+ * calls `assemble(context)` once per step. Registers the harness-owned
+ * `harness:identity` and `deployment:persona` sections itself (see
+ * {@link Config.persona}).
  */
 export class SystemPrompt extends Service {
+  static Config: z<Config> = z.object({
+    persona: z.string().default(''),
+  })
+
   private sections: PromptSection[] = []
   private toolProviders: (() => ToolSchema[])[] = []
   private variableProviders = new Map<string, (context: AssembleContext) => 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 ?? '',
+    })
   }
 
   /**

+ 67 - 24
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)', () => {

+ 3 - 0
packages/core/system-prompt/tsconfig.json

@@ -14,6 +14,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../llm/llm"
     }

+ 4 - 4
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.' }])

+ 4 - 3
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<Context> {
+export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
   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: [] })

+ 3 - 2
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'])
   })
 })
 

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

+ 4 - 1
packages/subagent/subagent-spawn/tests/harness.ts

@@ -23,7 +23,10 @@ export async function spawnHarness(workdir: string): Promise<Context> {
   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: [] })

+ 1 - 5
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 '

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

+ 4 - 3
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<Config> = z.object({
   toolName: z.string().default('subagent'),
   agentOptions: z.object({
     model: z.string(),
-    systemPrompt: z.string(),
   }),
 })
 

+ 2 - 2
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 () => {

+ 1 - 1
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`).

+ 15 - 12
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<Config> = 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 })
 }

+ 3 - 2
packages/ui/acp-agent/tests/acp-agent.spec.ts

@@ -24,7 +24,7 @@ async function mount(config: acpAgent.Config): Promise<Context> {
 
 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()

+ 2 - 1
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.
 

+ 1 - 5
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<AcpConfig> = 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 } : {},
   }
 }
 

+ 4 - 4
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')

+ 3 - 1
packages/ui/acp/tests/harness.ts

@@ -153,6 +153,8 @@ export interface BridgeHarness {
 export async function makeBridgeHarness(options: {
   script?: (StreamChunk[] | 'hang')[]
   config?: Partial<AcpConfig>
+  /** 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: [] })

+ 0 - 2
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' })
   })
 })

+ 3 - 3
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".

+ 10 - 9
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<Config> = 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<Config> = 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) } : {},
     }],
   })

+ 7 - 5
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<Context> {
 
 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',
     })

+ 8 - 0
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:^