From 739042a80434af610ec0e96684304edd04d0e87d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 22:22:57 +0800 Subject: [PATCH] feat(persona): make the agent persona a composable row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `{{…}}`. --- docs/config-catalog.md | 18 ++++ packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 1 + packages/preset/README.zh.md | 1 + packages/preset/persona/README.i18n.yaml | 6 ++ packages/preset/persona/README.md | 39 ++++++++ packages/preset/persona/README.zh.md | 39 ++++++++ packages/preset/persona/package.json | 43 +++++++++ packages/preset/persona/src/index.ts | 59 +++++++++++++ packages/preset/persona/src/invariant.ts | 30 +++++++ packages/preset/persona/tests/persona.spec.ts | 88 +++++++++++++++++++ packages/preset/persona/tsconfig.json | 25 ++++++ pnpm-lock.yaml | 19 ++++ tsconfig.host.json | 1 + 14 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 packages/preset/persona/README.i18n.yaml create mode 100644 packages/preset/persona/README.md create mode 100644 packages/preset/persona/README.zh.md create mode 100644 packages/preset/persona/package.json create mode 100644 packages/preset/persona/src/index.ts create mode 100644 packages/preset/persona/src/invariant.ts create mode 100644 packages/preset/persona/tests/persona.spec.ts create mode 100644 packages/preset/persona/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5542d296f1..1cb00d2b9b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index f0176c1bc6..034d0a90ff 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -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 diff --git a/packages/preset/README.md b/packages/preset/README.md index 7baac391c2..d2ed10014a 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -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. diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index 4d8c350b28..db7bf18e6b 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -7,6 +7,7 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | +| `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml new file mode 100644 index 0000000000..fa0933593c --- /dev/null +++ b/packages/preset/persona/README.i18n.yaml @@ -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 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md new file mode 100644 index 0000000000..789776b32d --- /dev/null +++ b/packages/preset/persona/README.md @@ -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. diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md new file mode 100644 index 0000000000..4e28d75bbd --- /dev/null +++ b/packages/preset/persona/README.zh.md @@ -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` 行自身的配置中修改。 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json new file mode 100644 index 0000000000..c5e968bd2b --- /dev/null +++ b/packages/preset/persona/package.json @@ -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" + } +} diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts new file mode 100644 index 0000000000..4bf24aea37 --- /dev/null +++ b/packages/preset/persona/src/index.ts @@ -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 = 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()') +} diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts new file mode 100644 index 0000000000..5f9068fe24 --- /dev/null +++ b/packages/preset/persona/src/invariant.ts @@ -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 */ diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts new file mode 100644 index 0000000000..bb7555df7c --- /dev/null +++ b/packages/preset/persona/tests/persona.spec.ts @@ -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 { + 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 { + 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.') + }) +}) diff --git a/packages/preset/persona/tsconfig.json b/packages/preset/persona/tsconfig.json new file mode 100644 index 0000000000..178bd54dbb --- /dev/null +++ b/packages/preset/persona/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a612f4af93..3bba564577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/tsconfig.host.json b/tsconfig.host.json index 021ff2e23b..043ed309fa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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" },