Merge remote-tracking branch 'origin/master' into worktree-dynamic-workflows
Beyond the mechanical conflicts (provider capability lines vs master's new inheritsParentContext field; generated catalogs regenerated rather than hand-merged; knip/lockfile), three master-side reworks required semantic adaptation of this branch: - The persona rework removed AgentOptions.systemPrompt, which was the structured-output instruction's channel. The instruction now rides the SAME final-request enforcement listener that injects the schema'd tool: appended per request to final.system (per-request wire state, not agent prompt state). Tests assert the wire request (adapter.requests) instead of child.options; the bare-direct-dispatch test pins the no-system arm. - Tool guidance moved out of deployment prompts into per-tool prompt sections; the examples' workflow paragraph became a tool:<toolName> section contributed by dsh-tool-workflow (explicit-ask-only policy), and both example personas resolve to master's minimal identity+behavior form. tool-workflow gains inject: systemPrompt (+ peer dep, tsconfig ref); the export-shape guard updated. - The uniform-RFC-format gate: the dynamic-workflows RFC restructured to the implemented/ skeleton (bare Status line; Proposal -> Decision; What-was-rejected -> Alternatives considered; new Consequences), and the overall-run-timeout deferral is now recorded in the RFC's Deferred list. The doc-graphs atlas classification gains the workflows seam (workflow-vm implementation, tool-workflow consumer). Master's harness-identity section made "empty assembled prompt" states unreachable through the loop, so the instruction-append is a plain undefined-ternary and the structured tests assert append-not-replace. All snapshot goldens (including workflow-run) replay unchanged. Full local CI-equivalent gate sequence green on the merged tree.
This commit is contained in:
@@ -18,6 +18,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
|
||||
## What it deliberately leaves OUTSIDE the bundle
|
||||
@@ -34,10 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// Config === AgentLoop.Config — the `agents` list, default [].
|
||||
// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
|
||||
// so validation and defaulting can never drift from the owners'.
|
||||
```
|
||||
|
||||
The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -44,5 +44,8 @@
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import z from 'schemastery'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
@@ -56,33 +57,45 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
|
||||
export const name = 'agent-core'
|
||||
|
||||
/**
|
||||
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
|
||||
* — an app that pre-creates no agents (the ACP bridge creates them on demand at
|
||||
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
|
||||
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
|
||||
* the forwarded shape can never drift.
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` to the system-prompt plugin (the
|
||||
* deployment's persona section). Both are optional INPUT here because each
|
||||
* owner's schema supplies the default (`[]` / `''`); the schema is the
|
||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
||||
* never drift from them.
|
||||
*/
|
||||
export type Config = AgentLoopConfig
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
}
|
||||
|
||||
/** Forward the loop's own schema so validation + defaulting stay identical. */
|
||||
export const Config = AgentLoop.Config
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||
* `agent-loop` receives the forwarded `agents` list. Load order is irrelevant
|
||||
* (cordis pends each fiber on its `inject` until the services it needs exist),
|
||||
* but the listing mirrors the dependency layering for readability: the LLM
|
||||
* vocabulary and core registries first, then the dev tripwire and the bash tool
|
||||
* consumer, then the loop that drives them.
|
||||
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
||||
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
|
||||
* its `inject` until the services it needs exist), but the listing mirrors the
|
||||
* dependency layering for readability: the LLM vocabulary and core registries
|
||||
* first, then the dev tripwire and the bash tool consumer, then the loop that
|
||||
* drives them.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(Timer)
|
||||
ctx.plugin(LlmService)
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(SystemPrompt)
|
||||
// The forwarded fields are validated + defaulted by this bundle's intersected
|
||||
// schema before apply runs, so the ?? fallbacks only narrow the
|
||||
// optional-input TYPES — they mirror the owners' schema defaults, never
|
||||
// introduce different ones.
|
||||
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(AgentLoop, { agents: config.agents })
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -43,11 +43,27 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a pre-created agent to the loop', async () => {
|
||||
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
|
||||
const ctx = await mount({
|
||||
agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }],
|
||||
agents: [{ id: AgentId('main'), model: 'mock' }],
|
||||
persona: 'You are main.',
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
|
||||
// ctx.plugin validates + defaults the bundle config first; a direct apply
|
||||
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('agents')?.list()).toHaveLength(0)
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
|
||||
@@ -28,12 +28,11 @@ interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
systemPrompt?: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup.
|
||||
Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
|
||||
### Classes
|
||||
|
||||
@@ -55,7 +54,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
|
||||
|
||||
@@ -72,7 +72,6 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
model: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
}) as unknown as z<Config>
|
||||
@@ -82,6 +81,16 @@ 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 prompt variables the shipped loop provides, registered once. The
|
||||
// sections themselves (`harness:identity`, `deployment:persona`) belong to
|
||||
// dsh-system-prompt — they must survive a swapped loop plugin — but
|
||||
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
|
||||
// it assembles with `{ agent }` each step (loop.ts), and the variables
|
||||
// project the agent's configured model and its session workspace from that
|
||||
// context. A provider returns undefined when the fact is absent
|
||||
// (renderPrompt then rejects a persona that claims it — fail loud).
|
||||
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
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
||||
const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
@@ -52,7 +52,7 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
||||
const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
@@ -92,7 +92,7 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
|
||||
@@ -8,11 +8,11 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
async function harness(adapter: MockAdapter, persona = '') {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
@@ -141,10 +141,12 @@ 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 harness identity, then the persona, then 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.' })
|
||||
// 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 ctx = await harness(adapter, 'You are a test agent on {{model}}.')
|
||||
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 +155,111 @@ describe('agent loop', () => {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
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 an AI agent powered by the DeepSeek Harness SDK.\n\nYou 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, 'Working in {{cwd}}.')
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nWorking in /work/space.')
|
||||
})
|
||||
|
||||
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', 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 same agent must then RUN a later turn to completion (not merely
|
||||
// report idle status): a rescue listener supplies the variable and the
|
||||
// follow-up prompt reaches the model.
|
||||
const adapter = new MockAdapter([textResponse('ok after rescue')])
|
||||
const ctx = await harness(adapter, 'In {{cwd}}.')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
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')
|
||||
|
||||
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
|
||||
// agent completes a real model turn.
|
||||
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
||||
assembly.variables['cwd'] = '/rescued'
|
||||
return next()
|
||||
})
|
||||
send(agent, 'again')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nIn /rescued.')
|
||||
const turnEnds = agent.session.events.filter(e => e.type === 'turn/end')
|
||||
expect(turnEnds).toHaveLength(2)
|
||||
expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => {
|
||||
// AgentOptions.model unset: the model arrives in the agent/request
|
||||
// waterfall (the loop's documented fallback — see runStep's no-model
|
||||
// error). {{model}} renders BEFORE that waterfall, so the SAME plugin
|
||||
// states the fact early on system-prompt/assemble — the owner of a
|
||||
// late-bound fact owns stating it wherever it is claimed.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'You run on {{model}}.')
|
||||
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
||||
assembly.variables['model'] = 'mock'
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
|
||||
options.model = 'mock'
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.model).toBe('mock')
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
// The documented escape valve: a deployment that must drop the harness
|
||||
// openers short-circuits the assemble waterfall; the request then carries
|
||||
// NO system field at all (not an empty string).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -371,10 +468,12 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the assembled system prompt.
|
||||
// One fire per step, in order, each with the assembled system prompt
|
||||
// (here just the loop's own harness-identity section — no persona set).
|
||||
const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, fullSystemPrompt: '' },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: '' },
|
||||
{ turn: 1, step: 1, fullSystemPrompt: HARNESS },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: HARNESS },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -796,7 +895,7 @@ describe('agent loop', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }],
|
||||
agents: [{ id: AgentId('config-agent'), model: 'mock' }],
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface CreateAgentOptions {
|
||||
* for a fresh (spawn) child.
|
||||
*/
|
||||
seed?: SessionEvent[]
|
||||
/** Per-agent options (model, system prompt). */
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface ResumeAgentOptions {
|
||||
agentId: AgentId
|
||||
/** The persisted session id to load and resume on. */
|
||||
resumeSessionId: SessionId
|
||||
/** Per-agent options (model, system prompt). */
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +56,28 @@ 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 })`; variable providers project per-agent
|
||||
* facts from it (`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.
|
||||
* Options an agent is created with. The persona is NOT here — it is the
|
||||
* deployment's `persona` config on the dsh-system-prompt plugin, shared by
|
||||
* every agent in the context.
|
||||
* Merge-extensible: plugins declare extra fields via declaration merging.
|
||||
*/
|
||||
export interface AgentOptions {
|
||||
/** Model name (must have a registered adapter at call time). */
|
||||
model?: string
|
||||
/** Per-agent system prompt appended after the assembled sections. */
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
# 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. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
|
||||
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||
|
||||
### 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: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
|
||||
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`, …); this plugin owns `harness:identity` and `deployment:persona`.
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly (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 deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.)
|
||||
- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`).
|
||||
|
||||
Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
* static `harness:identity` section (order −100) and the deployment's
|
||||
* `deployment:persona` section (order 0, from its `persona` config), so they
|
||||
* exist for every agent regardless of which loop plugin drives it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-system-prompt
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -17,30 +24,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: `-100` is the
|
||||
* harness identity, `0` the deployment persona, tool guidance uses 100–199;
|
||||
* other negative orders also 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,43 +90,156 @@ 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 at the scan position (validated after). */
|
||||
const GROUP_AT = /^\{\{([^{}]*)\}\}/
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
* system prompt, rendered as the order-0 `deployment:persona` section
|
||||
* (after the harness identity, before all tool guidance). Every agent in
|
||||
* the context shares it, subagents included. Template, not free-form text:
|
||||
* every complete `{{…}}` group is interpreted strictly against the
|
||||
* registered prompt variables (the shipped agent loop registers `{{model}}`
|
||||
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
|
||||
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
|
||||
* `''` — the empty section is dropped at render, so a persona-less
|
||||
* deployment opens with the harness identity alone.
|
||||
*/
|
||||
persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the text part of an assembly: interpolates `{{variable}}`
|
||||
* references in each section from `assembly.variables`, drops empty sections,
|
||||
* 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, a complete `{{…}}` group that is not a well-formed variable
|
||||
* name (e.g. `{{ model }}`), or a `{{` that does not open a complete group
|
||||
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
|
||||
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
|
||||
* through verbatim. Substituted values are never re-scanned.
|
||||
*/
|
||||
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 {
|
||||
const text = section.text
|
||||
let result = ''
|
||||
let last = 0
|
||||
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
|
||||
const group = GROUP_AT.exec(text.slice(open))
|
||||
if (group === null) {
|
||||
// No complete simple group starts at this `{{`. A `}}` further on means
|
||||
// a mangled reference (extra or nested braces) — fail loud. With no
|
||||
// closing `}}` anywhere after, it is ordinary prose (shell, JSON) and
|
||||
// passes through verbatim.
|
||||
if (text.indexOf('}}', open + 2) >= 0) {
|
||||
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`)
|
||||
}
|
||||
result += text.slice(last, open + 2)
|
||||
last = open + 2
|
||||
continue
|
||||
}
|
||||
// group[0] is the whole `{{...}}` match (a plain string, no optional
|
||||
// index): the name is its interior. `{{}}` yields '' → the malformed path.
|
||||
const name = group[0].slice(2, -2)
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
// Object.hasOwn, NOT `in`: `in` walks the prototype chain, so an
|
||||
// unregistered `{{constructor}}` would resolve to Object.prototype's and
|
||||
// splice a function's source text into the prompt instead of throwing.
|
||||
if (!Object.hasOwn(variables, name)) {
|
||||
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}")`)
|
||||
}
|
||||
result += text.slice(last, open) + value
|
||||
last = open + group[0].length
|
||||
}
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Registers the harness-owned
|
||||
* `harness:identity` and `deployment:persona` sections itself (see
|
||||
* {@link Config.persona}).
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
persona: z.string().default(''),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'systemPrompt')
|
||||
// The harness-owned openers. They live HERE (not on the loop plugin) so a
|
||||
// deployment that swaps in a different loop keeps them: the identity is a
|
||||
// harness fact stated ahead of everything, and the persona is the
|
||||
// deployment's config, one section of the full prompt, never the whole.
|
||||
// An empty persona still RESERVES the section name (one owner — a plugin
|
||||
// re-registering it throws); renderPrompt drops the empty text.
|
||||
this.section({
|
||||
name: 'harness:identity',
|
||||
order: -100,
|
||||
text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
|
||||
})
|
||||
this.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
// The schema already defaulted an omitted persona to ''; the ?? only
|
||||
// narrows the optional-input TYPE, it never supplies a different value.
|
||||
text: config.persona ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +283,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,21 +1,81 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Every assembly carries the plugin's own built-ins — `harness:identity`
|
||||
* (order −100) and `deployment:persona` (order 0, from config). Tests about
|
||||
* registry MECHANICS strip them with {@link contributed} to stay focused on
|
||||
* their own sections; the built-ins' behavior is pinned by its own describe.
|
||||
*/
|
||||
const BUILT_IN = ['harness:identity', 'deployment:persona']
|
||||
const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
|
||||
return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
|
||||
}
|
||||
|
||||
describe('SystemPrompt', () => {
|
||||
it('assembles sections in order with dynamic text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
describe('built-in sections', () => {
|
||||
it('registers the harness identity and the configured deployment persona', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => [s.name, s.order])).toEqual([
|
||||
['harness:identity', -100],
|
||||
['deployment:persona', 0],
|
||||
])
|
||||
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
|
||||
// The names are reserved by the plugin — one owner per section.
|
||||
expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' }))
|
||||
.toThrow('prompt section "deployment:persona" is already registered')
|
||||
})
|
||||
|
||||
it('renders no persona section for a persona-less deployment (empty default)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
|
||||
// ctx.plugin validates + defaults the config first; a direct construction
|
||||
// skips the schema, so the ctor's `?? ''` narrowing is what fires.
|
||||
const ctx = new Context()
|
||||
const service = new SystemPrompt(ctx, {})
|
||||
expect(renderPrompt(await service.assemble())).toBe(IDENTITY)
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles sections in order with context-resolved text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
|
||||
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
|
||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
|
||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['persona', 'rules', 'cwd'])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
|
||||
expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
|
||||
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
expect(renderPrompt(assembly)).toBe('You are DeepSeek Code.\n\nBe precise.\n\ncwd: /tmp')
|
||||
expect(assembly.variables).toEqual({})
|
||||
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
|
||||
})
|
||||
|
||||
it('resolves section text providers against the assemble context, at each assemble call', async () => {
|
||||
// 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(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice')
|
||||
expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody')
|
||||
})
|
||||
|
||||
it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
@@ -25,13 +85,30 @@ 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(contributed(before)).toHaveLength(1)
|
||||
expect(before.variables).toEqual({ scoped_var: 'v' })
|
||||
await fiber.dispose()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
expect(contributed(assembly)).toHaveLength(0)
|
||||
// The built-ins belong to the service fiber, so they survive the plugin's disposal.
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
expect(assembly.variables).toEqual({})
|
||||
})
|
||||
|
||||
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(contributed(assembly).map(s => s.text)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
@@ -47,12 +124,12 @@ describe('SystemPrompt', () => {
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked
|
||||
|
||||
// Subsequent listener-free register contributes exactly once.
|
||||
off()
|
||||
ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p'])
|
||||
expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
@@ -72,26 +149,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()
|
||||
expect(seen).toEqual([['base', 'from-a']])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['base', 'from-a'])
|
||||
const passed: AssembleContext = {}
|
||||
const assembly = await ctx.systemPrompt.assemble(passed)
|
||||
expect(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a'])
|
||||
expect(contexts[0]).toBe(passed) // the caller's context reaches listeners
|
||||
})
|
||||
|
||||
it('lets a waterfall listener short-circuit by not calling next()', async () => {
|
||||
@@ -100,7 +198,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 +213,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.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
|
||||
expect(second.sections[0]!.text).toBe(IDENTITY)
|
||||
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
|
||||
})
|
||||
|
||||
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 +250,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 () => {
|
||||
@@ -180,10 +270,10 @@ describe('SystemPrompt', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
|
||||
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes tool provider when returned disposer is called directly', async () => {
|
||||
@@ -196,4 +286,133 @@ 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 — the persona included', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You run on {{model}} in {{cwd}}.' })
|
||||
ctx.systemPrompt.variable('model', () => 'deepseek-v4')
|
||||
ctx.systemPrompt.variable('cwd', () => '/work')
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou 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(`${IDENTITY}\n\nfrom-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 {{ verbatim only when NO }} follows anywhere after it', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(text).toBe('shell ${X:-{{fallback} stays')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ text: '{{{model}}}', label: 'extra outer braces' },
|
||||
{ text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
|
||||
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference at')
|
||||
})
|
||||
|
||||
it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => {
|
||||
// `in` would find Object.prototype.constructor and splice function
|
||||
// source into the prompt; Object.hasOwn must reject it instead.
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('unknown prompt variable "{{constructor}}"')
|
||||
})
|
||||
|
||||
it('a variable NAMED like a prototype property works once actually registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' })
|
||||
ctx.systemPrompt.variable('constructor', () => 'own-value')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`)
|
||||
})
|
||||
|
||||
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }],
|
||||
tools: [],
|
||||
variables: { model: 'literal {{sneaky}} inside' },
|
||||
})
|
||||
expect(text).toBe('v = literal {{sneaky}} inside!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
|
||||
@@ -93,10 +93,13 @@ describe('gen-tool-catalog render', () => {
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/result'],
|
||||
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
|
||||
},
|
||||
]
|
||||
const md = render(catalog)
|
||||
expect(md).toContain('| `@deepseek-ai/dsh-tool-demo` | `demo` | `ctx.tools` | `tool/result` |')
|
||||
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
|
||||
expect(md).toContain('### `demo`')
|
||||
expect(md).toContain('A demo tool.')
|
||||
|
||||
Reference in New Issue
Block a user