feat(system-prompt): prompt variables, persona-as-section, tool-guidance ownership

One principle: every fact in the assembled prompt has exactly one owner.

- dsh-system-prompt: merge-extensible AssembleContext on assemble();
  a variable(name, provider) registry; {{name}} interpolation in
  renderPrompt, strict (unknown/valueless/malformed references throw);
  duplicate section and variable names rejected; assembly carries
  resolved section text + variables through the assemble waterfall.
- dsh-agent declares AssembleContext.agent; dsh-agent-loop registers
  the agent:persona section (order 0 - identity renders before tool
  guidance) and the model/cwd variables, and drops its string join:
  renderPrompt(assembly) IS the full prompt.
- Tool guidance moves to its owners: descriptions carry per-tool
  semantics; sections only cross-call habits (tool:bash exit-code
  habit at order 105; read's not-shell nudge). todo/subagent need no
  section - their descriptions already carry the contract.
- SubagentProvider.inheritsParentContext (spawn/acp false, fork true);
  dsh-tool-subagent derives truthful per-provider wording and resolves
  the provider at load (backend must be listed first).
- Example personas shrink to identity + behavior with {{model}} (and
  {{cwd}} in the ACP tree); the welcome banner stops enumerating tools.

RFC: docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
This commit is contained in:
Tianyi Cui
2026-07-05 01:54:46 +08:00
parent 1e2efc861e
commit f256f3961d
41 changed files with 746 additions and 177 deletions

View File

@@ -90,6 +90,8 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
*/
class AcpProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}

View File

@@ -62,6 +62,8 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
*/
class ForkProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
readonly inheritsParentContext = true
constructor(readonly name: string, private readonly ctx: Context) {}

View File

@@ -39,6 +39,8 @@ export const Config: z<Config> = z.object({
*/
class SpawnProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context) {}

View File

@@ -28,6 +28,8 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
## Run lifecycle
`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.

View File

@@ -163,6 +163,16 @@ export interface SubagentProvider {
readonly name: string
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* The provider's context contract: `true` when a child SEES the parent
* conversation (fork — the child is seeded with the parent's completed-turn
* prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact,
* not a start-time capability: the service validates nothing against it —
* the model-facing consumer (`dsh-tool-subagent`) derives truthful tool
* wording from it, so a tool bound to a fork provider stops telling the
* model the child "does not see this conversation".
*/
readonly inheritsParentContext: boolean
/**
* Start a child run. The service has already validated that every requested
* start-time capability is supported, so an implementation may assume e.g.

View File

@@ -22,6 +22,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false,
/** A scripted provider whose run settles immediately with a fixed result. */
class StubProvider implements SubagentProvider {
startCount = 0
readonly inheritsParentContext = false
constructor(
readonly name: string,
readonly capabilities: SubagentCapabilities = ALL_CAPS,
@@ -234,6 +235,7 @@ describe('SubagentService', () => {
ctx.subagents.registerProvider({
name: 'rej',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
result: Promise.reject(new Error('infra fault')),
@@ -267,6 +269,7 @@ describe('SubagentService', () => {
ctx.subagents.registerProvider({
name: 'unclone',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('unclone-child'),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult),
@@ -296,6 +299,7 @@ describe('SubagentService', () => {
ctx.subagents.registerProvider({
name: 'rejecter',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
result: Promise.reject(new Error('infra fault')),

View File

@@ -6,6 +6,10 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
## The description states the provider's context contract
The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, `apply` resolves the provider at LOAD time and **throws if it is not registered yet — list the backend plugin before this one in `cordis.yml`**; a wiring mistake fails loudly at boot instead of shipping a lying description.
| Config key | Meaning |
|---|---|
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |

View File

@@ -11,6 +11,13 @@
* — there is no provider/type parameter in the model-facing schema. The model
* sees only `{ description, prompt }`.
*
* The tool DESCRIPTION is derived from the bound provider's context contract
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
* standalone-prompt wording, an inheriting provider (fork) tells the model the
* child already sees the conversation's completed turns. `apply` therefore
* resolves the provider at load time and throws if it is not registered yet —
* list the backend plugin before this one in `cordis.yml`.
*
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
* `run.result` inside a `try/finally` that always disposes the run, so the
* owned child agent/session is torn down on every path (success, error, abort)
@@ -92,15 +99,56 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
/**
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
* A fresh child needs a standalone prompt; a forked child already sees the
* conversation's completed turns — telling the model to restate everything
* (or, worse, that the child "does not see this conversation") would be false
* for a fork. Exported for tests.
* @param inherits - the bound provider's context contract.
* @returns the tool `description` and the `prompt` parameter description.
*/
export function providerWording(inherits: boolean): { description: string; promptDescription: string } {
if (inherits) {
return {
description:
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
+ 'You receive only its final answer, not its intermediate steps.',
promptDescription:
'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '
+ 'freely and state only what is new.',
}
}
return {
description:
'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
+ 'and return its final result. Use this to offload focused, independent work — research, a scoped '
+ 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
+ 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
+ 'complete, standalone prompt: it does not see this conversation.',
promptDescription:
'The complete, self-contained task for the subagent. It does not share this '
+ 'conversation\'s context, so include everything it needs.',
}
}
export function apply(ctx: Context, config: Config): void {
// Resolve the bound provider NOW: the tool description must state the
// provider's context contract, so the backend plugin must be loaded before
// this one (list it earlier in cordis.yml). Fail loud at load, not with a
// lying description at model time.
const provider = ctx.subagents.getProvider(config.provider)
if (provider === undefined) {
throw new Error(
`subagent provider "${config.provider}" is not registered; load its backend plugin before tool-subagent`)
}
const wording = providerWording(provider.inheritsParentContext)
ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description,
parameters: {
description: {
type: 'string',
@@ -110,8 +158,7 @@ export function apply(ctx: Context, config: Config): void {
prompt: {
type: 'string',
required: true,
description: 'The complete, self-contained task for the subagent. It does not share this '
+ 'conversation\'s context, so include everything it needs.',
description: wording.promptDescription,
},
},
async execute(args, exec): Promise<ContentBlock[]> {

View File

@@ -112,6 +112,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'weird',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => ({
id: AgentId('weird-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
@@ -137,6 +138,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'capture',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: (request) => {
seen = request
return {
@@ -166,6 +168,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'bare',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: (request) => {
seen = request
return {
@@ -192,14 +195,36 @@ describe('dsh-tool-subagent', () => {
expect(text(result)).toContain('requires a calling agent')
})
it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here '
+ '(the tool requests no capabilities) — a missing provider IS surfaced', async () => {
// Bind the tool to a provider name that is not registered: the service throws
// NO_PROVIDER, the registry turns it into an isError result.
const ctx = await setup({ provider: 'does-not-exist' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no subagent provider')
it('fails loud AT LOAD when the bound provider is not registered (backend must load first)', async () => {
// The tool description states the provider's context contract, so apply()
// resolves the provider at load time — a missing backend is a wiring error
// surfaced immediately, not a lying description discovered at model time.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
await expect(async () => {
await ctx.plugin(tool, { provider: 'does-not-exist' })
await new Promise(r => setTimeout(r, 20))
}).rejects.toThrow('is not registered; load its backend plugin before tool-subagent')
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
})
it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('does not see this conversation')
const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties
expect(props['prompt']!.description).toContain('include everything it needs')
})
it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => {
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('INHERITS this conversation')
expect(schema.description).not.toContain('does not see this conversation')
const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties
expect(props['prompt']!.description).toContain('completed turns')
})
it('disposes the run on the success path (no leaked child)', async () => {
@@ -213,6 +238,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => ({
id: AgentId('spy-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
@@ -235,6 +261,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => ({
id: AgentId('spy-child'),
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
@@ -258,6 +285,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => {
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
@@ -304,6 +332,7 @@ describe('dsh-tool-subagent', () => {
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => {
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })