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:
@@ -2,7 +2,9 @@
|
||||
|
||||
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -43,11 +43,12 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash']
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
@@ -285,6 +286,15 @@ function statusLine(task: BashTask): string {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
|
||||
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
|
||||
|
||||
@@ -261,6 +261,14 @@ describe('bash tool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
|
||||
const ctx = await setup()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const section = assembly.sections.find(s => s.name === 'tool:bash')
|
||||
expect(section?.order).toBe(105)
|
||||
expect(section?.text).toContain('[exit code: N]')
|
||||
})
|
||||
|
||||
it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -268,8 +276,10 @@ 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'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tools depend on the executor: no registration without ctx.bash', async () => {
|
||||
|
||||
@@ -28,12 +28,12 @@ interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
systemPrompt?: string
|
||||
systemPrompt?: string // the agent's persona TEMPLATE (may reference {{model}}/{{cwd}})
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup.
|
||||
Agents listed in config are auto-created at startup. The plugin also registers the per-agent prompt pieces on `ctx.systemPrompt`: the `agent:persona` section (order 0 — `AgentOptions.systemPrompt` renders before all tool guidance) and the built-in `model`/`cwd` prompt variables, each resolved per step from the `assemble({ agent })` context.
|
||||
|
||||
### Classes
|
||||
|
||||
@@ -55,7 +55,7 @@ forever:
|
||||
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble()
|
||||
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
||||
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
|
||||
session('step/start')
|
||||
request = waterfall agent/request
|
||||
|
||||
@@ -82,6 +82,20 @@ 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 per-agent prompt pieces, registered once and resolved per assembly
|
||||
// from the AssembleContext the loop passes (loop.ts assembles with
|
||||
// `{ agent }` each step). The persona is the order-0 section — identity
|
||||
// renders before all tool guidance; `{{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: 'agent:persona',
|
||||
order: 0,
|
||||
text: context => context.agent?.options.systemPrompt ?? '',
|
||||
})
|
||||
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) {
|
||||
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
||||
// Resume a prior session instead of starting fresh. resume() needs
|
||||
|
||||
@@ -152,7 +152,8 @@ export interface LoopHandle {
|
||||
* every prompt blocked → 'turn/end'(rejected), 0 steps
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
|
||||
* session('step/start') ⟵ durable step boundary (no agent/* mirror)
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
@@ -434,11 +435,11 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// because the pre-step seam needs it: compaction measures token pressure
|
||||
// against the system prompt (it counts toward the budget). runStep reuses
|
||||
// this same assembly for the request, so the prompt is assembled once per
|
||||
// step.
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? '']
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
// step. renderPrompt IS the full prompt — the persona is the order-0
|
||||
// section (registered by the AgentLoop plugin) and `{{variable}}`
|
||||
// interpolation happens in the render, so there is no separate join.
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Interruption landing after assembly: dispose() or cancel() in a
|
||||
// turn-start listener (or a listener whose promise resolved before the
|
||||
|
||||
@@ -141,10 +141,10 @@ describe('agent loop', () => {
|
||||
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
|
||||
})
|
||||
|
||||
it('passes assembled system prompt and tool schemas into the request', async () => {
|
||||
it('renders the persona as the order-0 section — before tool guidance — with {{variables}} resolved', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' })
|
||||
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
@@ -153,16 +153,55 @@ describe('agent loop', () => {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
|
||||
// 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}}.' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const request = adapter.requests[0]
|
||||
expect(request!.system).toBe('You are a test agent.\n\nAgent-specific suffix.')
|
||||
expect(request!.system).toBe('You are a test agent on mock.\n\nUse the noop tool wisely.')
|
||||
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
|
||||
})
|
||||
|
||||
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 handle = ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
agentOptions: { model: 'mock', systemPrompt: 'Working in {{cwd}}.' },
|
||||
})
|
||||
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests[0]!.system).toBe('Working in /work/space.')
|
||||
})
|
||||
|
||||
it('contains a strict-variable render failure: the turn errors, the loop survives', async () => {
|
||||
// A persona claiming {{cwd}} on a session with NO cwd is a deployment
|
||||
// authoring error — renderPrompt throws, the turn ends with an error, and
|
||||
// the agent (and loop) stay alive for the next prompt.
|
||||
const adapter = new MockAdapter([textResponse('never reached'), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
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}}.' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0) // the request was never sent
|
||||
expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true)
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
expect(agent.status).toBe('idle') // contained: the loop is still serving
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -1016,7 +1016,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Blocking listener on the parent context (survives fiber disposal).
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocked
|
||||
return next()
|
||||
})
|
||||
@@ -1072,7 +1072,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
@@ -1229,7 +1229,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -25,12 +25,14 @@
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
@@ -55,6 +56,20 @@ export function AgentId(id: string): AgentId {
|
||||
}
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
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` →
|
||||
* `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics)
|
||||
* has no agent — providers must tolerate its absence.
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options an agent is created with.
|
||||
* Merge-extensible: plugins declare extra fields via declaration merging.
|
||||
@@ -62,7 +77,13 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
export interface AgentOptions {
|
||||
/** Model name (must have a registered adapter at call time). */
|
||||
model?: string
|
||||
/** Per-agent system prompt appended after the assembled sections. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
# dsh-system-prompt
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step.
|
||||
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.
|
||||
|
||||
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(): Promise<PromptAssembly>` Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly before it reaches the model |
|
||||
| `system-prompt/change` | emit | A section or tool provider was registered or unregistered |
|
||||
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
|
||||
| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered |
|
||||
|
||||
### Key types
|
||||
|
||||
- `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections are concatenated in ascending `order`.
|
||||
- `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`. 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)` — joins section texts with blank lines.
|
||||
- `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: `0` is the per-agent persona (registered by the agent loop), tool guidance uses `100–199`; negative orders 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, a registered-but-valueless reference, or a malformed complete `{{…}}` group throws (fail loud beats shipping a malformed prompt). Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim.
|
||||
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` via declaration merging.
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Section providers: AGENTS.md reader, cwd notifier, persona config, etc.
|
||||
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); the agent loop owns `agent: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 (system-prompt configurability, dynamic tool filtering).
|
||||
- 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.
|
||||
- Any hardcoded prompt text — every section comes from plugins, every deployment-authored word from config.
|
||||
- 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).
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections and
|
||||
* tool schema providers; `assemble()` collates them through a waterfall that
|
||||
* runs once per step.
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and
|
||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-system-prompt
|
||||
*/
|
||||
@@ -17,30 +18,63 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall around prompt assembly — mutate or extend the
|
||||
* {@link PromptAssembly} (sections + tool schemas) before it is rendered.
|
||||
* Bound to the {@link SystemPrompt} service; call `next()` to delegate.
|
||||
* @param assembly - the assembly built from the registered sections and
|
||||
* tool providers; listeners may mutate it or return a replacement.
|
||||
* {@link PromptAssembly} (sections + tools + variables) before it is
|
||||
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
|
||||
* delegate.
|
||||
* @param assembly - the assembly built from the registered sections, tool
|
||||
* providers, and variable providers; listeners may mutate it or return a
|
||||
* replacement.
|
||||
* @param context - the per-assembly {@link AssembleContext} the caller
|
||||
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
|
||||
* is for), so a listener can filter or extend per agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/**
|
||||
* A section or tool provider was registered or unregistered (the assembly
|
||||
* inputs changed).
|
||||
* A section, tool provider, or variable provider was registered or
|
||||
* unregistered (the assembly inputs changed).
|
||||
* @mode emit
|
||||
*/
|
||||
'system-prompt/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/** One contributed section of the system prompt. */
|
||||
/**
|
||||
* Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
|
||||
* Declared empty here so this package stays agnostic of who assembles;
|
||||
* merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so
|
||||
* section text and variable providers can be functions of the calling agent.
|
||||
* Every field is optional by nature: a bare `assemble()` (tests, diagnostics)
|
||||
* carries an empty context, and providers must tolerate absent fields.
|
||||
*/
|
||||
export interface AssembleContext {}
|
||||
|
||||
/** One contributed section of the system prompt (registry input). */
|
||||
export interface PromptSection {
|
||||
/** Unique name (diagnostics / dedup). */
|
||||
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
|
||||
name: string
|
||||
/** Sections are concatenated in ascending order. */
|
||||
/**
|
||||
* Sections are concatenated in ascending order. Convention: `0` is the
|
||||
* per-agent persona, tool guidance uses 100–199; negative orders render
|
||||
* before the persona.
|
||||
*/
|
||||
order: number
|
||||
/** Static text or a provider evaluated at each assembly. */
|
||||
text: string | (() => string)
|
||||
/**
|
||||
* Static text or a provider evaluated at each assembly with that assembly's
|
||||
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
|
||||
* interpolated later, by {@link renderPrompt}.
|
||||
*/
|
||||
text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
export interface AssembledSection {
|
||||
/** The contributing section's unique name. */
|
||||
name: string
|
||||
/** The contributing section's order (sections arrive sorted ascending). */
|
||||
order: number
|
||||
/** The resolved (but not yet interpolated) section text. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,29 +84,72 @@ export interface PromptSection {
|
||||
* can do" is one coherent thing managed here, even though adapters transmit
|
||||
* `tools` as a separate wire field rather than prompt text.
|
||||
*
|
||||
* `variables` carries every registered prompt variable resolved against this
|
||||
* assembly's context — key present means registered, `undefined` value means
|
||||
* "no value for this assembly" (referencing it renders an error). Section
|
||||
* texts are resolved but NOT yet interpolated; {@link renderPrompt} applies
|
||||
* the variables, so waterfall listeners can still add sections or variables.
|
||||
*
|
||||
* Merge-extensible: plugins can declare extra fields on this interface.
|
||||
*/
|
||||
export interface PromptAssembly {
|
||||
sections: PromptSection[]
|
||||
sections: AssembledSection[]
|
||||
tools: ToolSchema[]
|
||||
variables: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/** Renders the text part of an assembly (sections joined by blank lines). */
|
||||
/** Valid variable names: how they are written between the braces. */
|
||||
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/** A complete `{{...}}` reference group (any inner content, validated after). */
|
||||
const REFERENCE = /\{\{([^{}]*)\}\}/g
|
||||
|
||||
/**
|
||||
* Renders the text part of an assembly: interpolates `{{variable}}`
|
||||
* references in each section from `assembly.variables`, drops empty sections,
|
||||
* and joins the rest with blank lines.
|
||||
*
|
||||
* Strict by design (fail loud beats shipping a malformed prompt): a reference
|
||||
* to an unregistered variable, to a registered variable with no value for
|
||||
* this assembly, or a complete `{{...}}` group that is not a well-formed
|
||||
* variable name (e.g. `{{ model }}`) throws. Only complete double-brace
|
||||
* groups are interpreted; a lone `{{` without a closing `}}` passes through
|
||||
* verbatim.
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
.map(section => typeof section.text === 'function' ? section.text() : section.text)
|
||||
.map(section => interpolate(section, assembly.variables))
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
|
||||
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
|
||||
return section.text.replace(REFERENCE, (_match, name: string) => {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
if (!(name in variables)) {
|
||||
const known = Object.keys(variables)
|
||||
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
|
||||
}
|
||||
const value = variables[name]
|
||||
if (value === undefined) {
|
||||
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`)
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections and tool-schema providers; the agent loop calls `assemble()` once
|
||||
* per step.
|
||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
||||
* calls `assemble(context)` once per step.
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'systemPrompt')
|
||||
@@ -80,13 +157,18 @@ export class SystemPrompt extends Service {
|
||||
|
||||
/**
|
||||
* Contribute a text section to the system prompt. Order is determined by
|
||||
* `section.order` (ascending). The section is removed when the calling
|
||||
* `section.order` (ascending). Throws if a section with the same name is
|
||||
* already registered (a duplicate would silently double prompt text — e.g.
|
||||
* a double-loaded tool plugin). The section is removed when the calling
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
* @param section - the section to contribute (name, order, text or provider).
|
||||
* @returns the disposer that removes the section.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (this.sections.some(existing => existing.name === section.name)) {
|
||||
throw new Error(`prompt section "${section.name}" is already registered`)
|
||||
}
|
||||
this.sections.push(section)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
@@ -130,25 +212,71 @@ export class SystemPrompt extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt (sections sorted by order, tools collected
|
||||
* from all providers). Section records are top-level clones (the `text`
|
||||
* provider may be a function and is intentionally shared); tool schemas are
|
||||
* deep-cloned because adapters and request waterfalls may mutate schema
|
||||
* objects. Runs through the `system-prompt/assemble` waterfall, giving
|
||||
* listeners the opportunity to mutate or replace the assembly before it
|
||||
* reaches the model. Await the result before reading the assembly values —
|
||||
* waterfall listeners may be async.
|
||||
* Contribute a named prompt variable, referenced from section text as
|
||||
* `{{name}}`. The provider is evaluated at each assembly with that
|
||||
* assembly's {@link AssembleContext}; returning `undefined` means "no value
|
||||
* for this assembly" (a section referencing it then fails to render — a
|
||||
* deployment must not claim facts it does not have). Throws on a name that
|
||||
* does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is
|
||||
* already registered. Removed when the calling fiber is disposed; emits
|
||||
* `system-prompt/change` on register/unregister.
|
||||
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
|
||||
* @param provider - evaluated at every {@link assemble} for the value.
|
||||
* @returns the disposer that removes the variable.
|
||||
*/
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
if (this.variableProviders.has(name)) {
|
||||
throw new Error(`prompt variable "${name}" is already registered`)
|
||||
}
|
||||
this.variableProviders.set(name, provider)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
|
||||
yield () => {
|
||||
this.variableProviders.delete(name)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: section texts are resolved
|
||||
* against `context` and sorted by order, tools collected from all
|
||||
* providers, and every registered variable resolved against `context` into
|
||||
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
|
||||
* request waterfalls may mutate schema objects. Runs through the
|
||||
* `system-prompt/assemble` waterfall, giving listeners the opportunity to
|
||||
* mutate or replace the assembly before it reaches the model. Await the
|
||||
* result before reading the assembly values — waterfall listeners may be
|
||||
* async. Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
* @returns the assembly after the waterfall has run.
|
||||
*/
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const assembly: PromptAssembly = {
|
||||
sections: this.sections
|
||||
.map(section => ({ ...section }))
|
||||
.map(section => ({
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
tools: this.toolProviders.flatMap(provider =>
|
||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { PromptAssembly, PromptSection, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
describe('SystemPrompt', () => {
|
||||
it('assembles sections in order with dynamic text and collected tools', async () => {
|
||||
it('assembles sections in order with context-resolved text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -14,10 +14,28 @@ describe('SystemPrompt', () => {
|
||||
|
||||
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 Code.', '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 Code.\n\nBe precise.\n\ncwd: /tmp')
|
||||
})
|
||||
|
||||
it('resolves section text providers against the assemble context, at each assemble call', async () => {
|
||||
// The context is HOW per-agent sections work (the loop passes { agent });
|
||||
// this spec stays agent-agnostic and smuggles a marker through a plain field.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let calls = 0
|
||||
ctx.systemPrompt.section({
|
||||
name: 'dynamic',
|
||||
order: 0,
|
||||
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')
|
||||
})
|
||||
|
||||
it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -25,13 +43,28 @@ describe('SystemPrompt', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
|
||||
inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
|
||||
inner.systemPrompt.variable('scoped_var', () => 'v')
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1)
|
||||
const before = await ctx.systemPrompt.assemble()
|
||||
expect(before.sections).toHaveLength(1)
|
||||
expect(before.variables).toEqual({ scoped_var: 'v' })
|
||||
await fiber.dispose()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
expect(assembly.variables).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' })
|
||||
expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' }))
|
||||
.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'])
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
@@ -72,26 +105,47 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order', async () => {
|
||||
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked
|
||||
|
||||
off()
|
||||
ctx.systemPrompt.variable('v', () => 'x')
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' })
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
|
||||
|
||||
// Listener A appends a section, then delegates.
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
const contexts: AssembleContext[] = []
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
|
||||
contexts.push(context)
|
||||
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
|
||||
return next()
|
||||
})
|
||||
// Listener B (registered later, runs after A) sees A's contribution.
|
||||
const seen: string[][] = []
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
|
||||
seen.push(assembly.sections.map(s => s.name))
|
||||
return next()
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
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(contexts[0]).toBe(passed) // the caller's context reaches listeners
|
||||
})
|
||||
|
||||
it('lets a waterfall listener short-circuit by not calling next()', async () => {
|
||||
@@ -100,7 +154,7 @@ describe('SystemPrompt', () => {
|
||||
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
|
||||
|
||||
ctx.on('system-prompt/assemble', async () => {
|
||||
return { sections: [], tools: [] } satisfies PromptAssembly
|
||||
return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
@@ -115,40 +169,33 @@ describe('SystemPrompt', () => {
|
||||
|
||||
const first = await ctx.systemPrompt.assemble()
|
||||
first.sections[0]!.name = 'mutated'
|
||||
first.sections[0]!.text = 'mutated'
|
||||
first.tools[0]!.description = 'mutated'
|
||||
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
|
||||
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.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
|
||||
})
|
||||
|
||||
it('filters out empty section text from renderPrompt', () => {
|
||||
// Direct test of renderPrompt: function returning empty string, and empty static text
|
||||
const result = renderPrompt({
|
||||
sections: [
|
||||
{ name: 'empty-fn', order: 0, text: () => '' },
|
||||
{ name: 'empty', order: 0, text: '' },
|
||||
{ name: 'real', order: 1, text: 'content' },
|
||||
{ name: 'empty-static', order: 2, text: '' },
|
||||
],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(result).toBe('content')
|
||||
})
|
||||
|
||||
it('evaluates dynamic function-text sections at each renderPrompt call', () => {
|
||||
let counter = 0
|
||||
const section: PromptSection = { name: 'dynamic', order: 0, text: () => `call ${++counter}` }
|
||||
expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 1')
|
||||
expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 2')
|
||||
})
|
||||
|
||||
it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const changes: number = 0
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
|
||||
@@ -159,7 +206,6 @@ describe('SystemPrompt', () => {
|
||||
dispose()
|
||||
// disposal emits change again
|
||||
expect(changeCount).toBe(2)
|
||||
void changes // silence unused
|
||||
})
|
||||
|
||||
it('cleans up tool providers on fiber dispose', async () => {
|
||||
@@ -196,4 +242,96 @@ describe('SystemPrompt', () => {
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('prompt variables', () => {
|
||||
it('resolves each variable against the assemble context and emits change on register/unregister', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
|
||||
const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who)
|
||||
expect(changeCount).toBe(1)
|
||||
|
||||
expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' })
|
||||
// A provider returning undefined records "registered but no value here".
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
|
||||
|
||||
dispose()
|
||||
expect(changeCount).toBe(2)
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a duplicate variable name and an unreferenceable name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.variable('model', () => 'm1')
|
||||
expect(() => ctx.systemPrompt.variable('model', () => 'm2'))
|
||||
.toThrow('prompt variable "model" is already registered')
|
||||
expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x'))
|
||||
.toThrow('invalid prompt variable name "Not Valid"')
|
||||
// Neither failed registration leaked.
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' })
|
||||
})
|
||||
|
||||
it('interpolates {{name}} references in section text at render', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: '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.')
|
||||
})
|
||||
|
||||
it('lets a waterfall listener add or override variables before render', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' })
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
|
||||
assembly.variables['extra'] = 'from-waterfall'
|
||||
return next()
|
||||
})
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('from-waterfall')
|
||||
})
|
||||
|
||||
it('throws on a reference to an unregistered variable, listing what exists', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' })
|
||||
ctx.systemPrompt.variable('model', () => 'm')
|
||||
await expect(async () => renderPrompt(await ctx.systemPrompt.assemble()))
|
||||
.rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model')
|
||||
})
|
||||
|
||||
it('names "(none)" when no variables are registered at all', () => {
|
||||
expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} }))
|
||||
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
|
||||
})
|
||||
|
||||
it('throws when a referenced variable has no value for this assembly', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }],
|
||||
tools: [],
|
||||
variables: { cwd: undefined },
|
||||
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
|
||||
})
|
||||
|
||||
it('throws on a malformed complete reference, e.g. inner spaces', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'on {{ model }}' }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
|
||||
})
|
||||
|
||||
it('leaves a lone {{ without a closing }} verbatim (only complete groups are interpreted)', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(text).toBe('shell ${X:-{{fallback} stays')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
text: 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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`, …). |
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -14,6 +14,7 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa
|
||||
| `reply` | `mock subagent reply` | The scripted child's final answer text. |
|
||||
| `stopReason` | `completed` | The stop reason `result` settles with. |
|
||||
| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. |
|
||||
| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. |
|
||||
| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. |
|
||||
|
||||
A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable.
|
||||
|
||||
@@ -37,12 +37,14 @@ const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: tru
|
||||
*/
|
||||
class MockSubagentProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities
|
||||
readonly inheritsParentContext: boolean
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly config: Config,
|
||||
) {
|
||||
this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities }
|
||||
this.inheritsParentContext = config.inheritsParentContext ?? false
|
||||
}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
@@ -88,6 +90,12 @@ export interface Config {
|
||||
stopReason?: SubagentStopReason
|
||||
/** Which start-time capabilities to advertise (default: all `true`). */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/**
|
||||
* The context contract to declare ({@link SubagentProvider.inheritsParentContext});
|
||||
* default `false` (spawn-like). Set `true` to exercise the fork-shaped tool
|
||||
* wording in consumer tests.
|
||||
*/
|
||||
inheritsParentContext?: boolean
|
||||
/**
|
||||
* Structured value surfaced when a request carries an `outputSchema` and the
|
||||
* `outputSchema` capability is on (default: `{ reply }`).
|
||||
@@ -104,6 +112,7 @@ export const Config: z<Config> = z.object({
|
||||
depthLimit: z.boolean(),
|
||||
toolFilter: z.boolean(),
|
||||
}),
|
||||
inheritsParentContext: z.boolean(),
|
||||
structured: z.any(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 system prompt |
|
||||
| `systemPrompt` | (required) | the per-session agent's persona template (may reference `{{model}}`/`{{cwd}}`) |
|
||||
| `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,7 +15,7 @@ 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 system prompt. |
|
||||
| `systemPrompt` | — | Per-agent persona template (may reference `{{model}}`/`{{cwd}}`). |
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 system prompt |
|
||||
| `systemPrompt` | (required) | the `main` agent's persona template (may reference `{{model}}`) |
|
||||
| `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. Your only tools are bash…'
|
||||
systemPrompt: 'You are a CLI 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".
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('tool-web registration', () => {
|
||||
it('contributes prompt sections for the enabled tools', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => (typeof s.text === 'function' ? s.text() : s.text)).join('\n')
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain('web_search')
|
||||
expect(text).toContain('web_fetch')
|
||||
await fiber.dispose()
|
||||
|
||||
Reference in New Issue
Block a user