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).
This commit is contained in:
Tianyi Cui
2026-07-05 23:23:46 +08:00
parent 2304f7a245
commit 3f83a4ee96
55 changed files with 389 additions and 284 deletions

View File

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

View File

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

View File

@@ -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 ?? '',
})
}
/**

View File

@@ -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)', () => {

View File

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