feat(persona): make the agent persona a composable row

`dsh-system-prompt` owns the deployment persona as its own config and registers
that section unconditionally, so a process has exactly one. An agent preset
cannot mount the prompt registry itself, which means that without a row of its
own a preset could change an agent's tools but never its identity — and a
roster of presets that all sound the same is not worth having.

The row is scope-only by construction: mounted outside an agent scope it
collides with the registry's own `deployment:persona` registration and fails
loud. That is the correct shape rather than a gap, because the unscoped slot
already has an owner and this row exists to shadow it for one agent.

Two behaviours are pinned by test because both read the other way at a glance:
an empty persona still occupies the slot (shadowing the deployment persona away
entirely, then disappearing at render), and `assemble()` keeps section text
uninterpolated — `renderPrompt()` is the stage that resolves `{{…}}`.
This commit is contained in:
Yichen Jiang
2026-08-03 22:22:57 +08:00
parent e27d38efd6
commit 739042a804
14 changed files with 371 additions and 2 deletions

View File

@@ -1022,6 +1022,24 @@ Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMod
Source: [`packages/ui/permission/src/index.ts:140`](../packages/ui/permission/src/index.ts)
## `@deepseek-ai/dsh-persona`
Requires: `systemPrompt`
```ts config-catalog
/** Plugin config: the persona text this composition contributes. */
export interface Config {
/**
* Persona prose rendered as the `deployment:persona` section. A template:
* complete `{{…}}` groups interpolate strictly against registered prompt
* variables. Empty text drops the section at render, matching the registry.
*/
text: string
}
```
Source: [`packages/preset/persona/src/index.ts:33`](../packages/preset/persona/src/index.ts)
## `@deepseek-ai/dsh-plan-mode`
Requires: `tools` · `systemPrompt`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/README.md
README.md: 7baac391c224f717b60edeb0de828cb004ab460a
README.zh.md: 4d8c350b2831ae9c506ad7756ae157bf313a1c3e
README.md: fcce3013174b5c3b7e545eb48e73cc1d8f4126dc
README.zh.md: 281885daae9aab8dc989c232e751f9a255075b96

View File

@@ -7,6 +7,7 @@ An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it u
| Package | Role | ctx key |
|---|---|---|
| `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` |
| `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — |
The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session.

View File

@@ -7,6 +7,7 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` |
| `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — |
本组假定的组装划分是注册表与跨会话设施是进程单例留在宿主组装中preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/persona/README.md
README.md: 3a9f9f2e2debf9d4274949e14913137ef752baae
README.zh.md: 2c3bac3bb4a1fbeeb9c30defb225c2b64cb3e577

View File

@@ -0,0 +1,39 @@
# dsh-persona
English | [中文](README.zh.md)
The agent persona as a composable row. One config field, one prompt section.
[`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row.
## Scope-only
Mounting this row outside an agent scope collides with the registry's own `deployment:persona` registration and fails loud. That is not a limitation to work around: the deployment persona already has an owner, and the whole point of this row is to shadow it for one agent. Mount it inside a preset composition, where the preset mount supplies the agent scope.
## Config
| Field | Default | Meaning |
|---|---|---|
| `text` | required | Persona prose rendered as the `deployment:persona` section |
`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render.
## Model Experience
### The persona section
#### What the model sees
The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured.
#### Token effect
Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing.
#### KV Cache effect
Prefix-stable for the life of an agent — the row mounts once, before the agent is published and therefore before its first request, and its text never changes while the agent runs. Two agents on different presets establish different prefixes from this section onward; neither can invalidate the other's reuse.
## Known Limitations and Deferred Work
- **No global mount** — the prompt registry owns the unscoped persona slot, so this row is usable only from a scoped composition. A deployment-wide persona change belongs in the `system-prompt` row's own config.

View File

@@ -0,0 +1,39 @@
# dsh-persona
[English](README.md) | 中文
把 agent智能体人设做成一个可组装的行一个配置字段一个提示词段落。
[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。
## 仅限 scope 内使用
在 agent scope 之外挂载本行,会与注册表自身的 `deployment:persona` 注册相撞并明确报错。这不是需要绕开的限制:部署级人设已经有归属,而本行存在的意义正是为某一个 agent 遮蔽它。请把它挂在 preset 组装内部,由 preset 的挂载过程提供 agent scope。
## 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 |
`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。
## Model Experience
### 人设段落
#### What the model sees
位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent它会替换部署所配置的任何人设。
#### Token effect
对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token其他 agent 一个都不带。空文本不贡献任何 token。
#### KV Cache effect
在一个 agent 的整个生命周期内保持前缀稳定——本行只挂载一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间文本不再改变。两个使用不同 preset 的 agent 从该段落起建立各自不同的前缀,谁都无法让对方失去缓存复用。
## Known Limitations and Deferred Work
- **不支持全局挂载** —— 提示词注册表拥有未加 scope 的人设槽位,因此本行只能从带 scope 的组装中使用。要改变部署级人设,应在 `system-prompt` 行自身的配置中修改。

View File

@@ -0,0 +1,43 @@
{
"name": "@deepseek-ai/dsh-persona",
"description": "Composition-authored deployment persona section for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,59 @@
/**
* A per-agent persona as a composable row.
*
* `dsh-system-prompt` owns the global persona as its own config, and registers
* that section unconditionally — so this row is **scope-only**. Mounted inside
* an agent preset it shadows the deployment persona for that one session,
* exactly like the per-child persona `dsh-subagent` installs; mounted globally
* it collides with the registry's own registration and fails loud.
*
* That constraint is the reason the row exists. An agent preset cannot mount
* the prompt registry itself, so without a row of its own a preset could
* change an agent's tools but never its identity.
* @module @deepseek-ai/dsh-persona
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-system-prompt'
/** The section name this plugin registers; the prompt registry's persona slot. */
export const PERSONA_SECTION = 'deployment:persona'
/** Prompt order of the persona slot, matching the registry's own default. */
export const PERSONA_ORDER = 0
/** Cordis plugin name. */
export const name = 'persona'
/** The prompt registry this row contributes to. */
export const inject = ['systemPrompt']
/** Plugin config: the persona text this composition contributes. */
export interface Config {
/**
* Persona prose rendered as the `deployment:persona` section. A template:
* complete `{{…}}` groups interpolate strictly against registered prompt
* variables. Empty text drops the section at render, matching the registry.
*/
text: string
}
/** Runtime schema for the persona row. */
export const Config: z<Config> = z.object({
text: z.string().required(),
})
/**
* Register the persona section for the mounting context's scope.
* @param ctx - an agent scope context; an unscoped context collides with the
* prompt registry's own persona registration and rejects.
* @param config - the persona text.
*/
export function apply(ctx: Context, config: Config): void {
ctx.effect(() => ctx.systemPrompt.section({
name: PERSONA_SECTION,
order: PERSONA_ORDER,
text: config.text,
}), 'persona.section()')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-persona`.
* @module @deepseek-ai/dsh-persona/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-persona'
/** Cordis companion plugin name. */
export const name = 'persona-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this row owns no event stream or mutable runtime data — it registers one
* prompt section and the prompt registry owns section identity, shadowing, and disposal.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,88 @@
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope'
import { describe, expect, it } from 'vitest'
import * as Persona from '@deepseek-ai/dsh-persona'
import { PERSONA_SECTION } from '@deepseek-ai/dsh-persona'
async function harness(deploymentPersona: string): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { persona: deploymentPersona })
return ctx
}
/** The rendered text of the persona slot as one scope sees it. */
async function personaText(ctx: Context, scope?: ScopeKey): Promise<string | undefined> {
const assembly = await ctx.systemPrompt.assemble(scope === undefined ? {} : { scope })
return assembly.sections.find(section => section.name === PERSONA_SECTION)?.text
}
describe('the persona row', () => {
it('rejects an unscoped mount, which would collide with the registry default', async () => {
const ctx = await harness('deployment identity')
await expect(ctx.plugin(Persona, { text: 'composition identity' }))
.rejects.toThrow(/"deployment:persona" is already registered/)
})
it('shadows the deployment default for one scope only', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
const scope = createScope(ctx, key)
await scope.ctx.plugin(Persona, { text: 'preset identity' })
expect(await personaText(ctx, key)).toBe('preset identity')
expect(await personaText(ctx)).toBe('deployment identity')
})
it('gives two scopes independent personas', async () => {
const ctx = await harness('')
const first: ScopeKey = { agent: 'a1' }
const second: ScopeKey = { agent: 'a2' }
await createScope(ctx, first).ctx.plugin(Persona, { text: 'first identity' })
await createScope(ctx, second).ctx.plugin(Persona, { text: 'second identity' })
expect(await personaText(ctx, first)).toBe('first identity')
expect(await personaText(ctx, second)).toBe('second identity')
})
it('shadows the deployment persona away entirely when its text is empty', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
await createScope(ctx, key).ctx.plugin(Persona, { text: '' })
// The slot is still occupied, so the deployment persona is gone for this
// agent; an empty section is dropped when the prompt renders.
expect(await personaText(ctx, key)).toBe('')
expect(await personaText(ctx)).toBe('deployment identity')
})
it('restores the shadowed default when its fiber unloads', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
const scope = createScope(ctx, key)
const fiber = await scope.ctx.plugin(Persona, { text: 'preset identity' })
expect(await personaText(ctx, key)).toBe('preset identity')
await fiber.dispose()
expect(await personaText(ctx, key)).toBe('deployment identity')
})
it('interpolates prompt variables strictly, like any other section', async () => {
const ctx = await harness('')
const key: ScopeKey = { agent: 'a1' }
ctx.systemPrompt.variable('model', () => 'deepseek-v4-pro')
await createScope(ctx, key).ctx.plugin(Persona, { text: 'You run on {{model}}.' })
// `assemble()` keeps section text uninterpolated; `renderPrompt()` is the
// stage that resolves `{{…}}` against the assembly's variables.
expect(await personaText(ctx, key)).toBe('You run on {{model}}.')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key })))
.toContain('You run on deepseek-v4-pro.')
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
}
]
}

19
pnpm-lock.yaml generated
View File

@@ -4203,6 +4203,25 @@ importers:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/preset/persona:
dependencies:
schemastery:
specifier: ^3.18.0
version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/pty/pty:
devDependencies:
'@deepseek-ai/dsh-agent':

View File

@@ -218,6 +218,7 @@
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/plan/plan-mode" },
{ "path": "./packages/preset/agent-presets" },
{ "path": "./packages/preset/persona" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/cordis/repository-plugin" },