Merge origin/master into codex/status-bar-token-metrics

Resolve the agent-loop import conflict by retaining both durable request context and runtime policy context. Refresh the combined session fixtures and regenerate documentation catalogs. Mark PDF artifacts as binary so staged whitespace checks do not parse PDF bytes as text.
This commit is contained in:
Hypatia May
2026-07-31 16:53:03 +08:00
249 changed files with 72476 additions and 2957 deletions

View File

@@ -34,6 +34,7 @@ import {
LlmError,
assertNever,
createAssistantMessage,
createUserMessage,
deepFreeze,
errorChain,
freezeMessage,
@@ -45,7 +46,7 @@ import {
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -54,6 +55,47 @@ type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
/** Whether one user message is owned by runtime-context materialization. */
function isRuntimeContextMessage(message: UserMessage): boolean {
return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE
}
/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */
function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } {
const events = session.events
const nodes = session.surface.nodes
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const event = events[nodes[index] as number]
if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue
const [block] = event.data.content
return {
found: true,
text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined,
}
}
return { found: false, text: undefined }
}
/** Append a full current snapshot only when it changed or compaction removed it. */
function materializeRuntimeContext(session: Session, current: string): void {
const previous = retainedRuntimeContext(session)
if (!previous.found && current.length === 0) {
const compactedPriorSnapshot = session.surface.replaceGeneration > 0
&& session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data))
if (!compactedPriorSnapshot) return
}
const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
if (previous.text === snapshot) return
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: snapshot }],
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
}), { surfaceOp: 'append' })
}
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -520,10 +562,13 @@ export class ReactLoopAgent implements Agent {
// this request together.
this.drainOutbox(turn)
// Assemble the system prompt fresh each step (it may depend on log state).
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
// committed at the tail before deriving history once, preserving the stable
// system/history cache prefix while keeping every model-visible byte logged.
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)
materializeRuntimeContext(session, renderContextSnapshot(assembly))
// Snapshot the exact log prefix: the reconstruction boundary. Appends
// after this synchronous snapshot join the next request.

View File

@@ -254,7 +254,7 @@ describe('agent loop', () => {
// 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: {} }))
ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
@@ -264,6 +264,178 @@ describe('agent loop', () => {
expect('system' in adapter.requests[0]!).toBe(false)
})
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
const adapter = new MockAdapter([
textResponse('one'),
textResponse('two'),
textResponse('three'),
textResponse('four'),
textResponse('five'),
])
const ctx = await harness(adapter)
let mode = 'read-only'
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
const contextEvents = () => agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(1)
expect(contextEvents()[0]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
}])
send(agent, 'unchanged')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(1)
mode = 'danger-full-access'
send(agent, 'changed')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(2)
const changedBlock = contextEvents()[1]?.data.content[0]
expect(changedBlock?.type).toBe('text')
if (changedBlock?.type !== 'text') throw new Error('changed runtime context is not text')
expect(changedBlock.text).toContain('danger-full-access')
dispose()
send(agent, 'cleared')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(3)
expect(contextEvents()[2]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
}])
send(agent, 'still clear')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(3)
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
})
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
})
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
expect(adapter.requests[1]?.messages.some(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
})
it('clears compacted runtime context after the active set becomes empty', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary retaining old mode: read-only' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
})
dispose()
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
const clearing = adapter.requests[1]?.messages.find(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')
expect(clearing?.content).toEqual([{
type: 'text',
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
}])
})
it('does not clear runtime context after an unrelated replacement', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' })
const original = agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'old context' }],
source: { kind: 'plugin', plugin: 'test-context' },
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]?.messages.some(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
})
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
}), { surfaceOp: 'append' })
send(agent, 'repair context')
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
expect(runtimeContexts[1]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
}])
})
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
README.zh.md: 1fd4febc1c15acda19e7abfca94079b9585c1972
README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e
README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
Model-input assembly registry. Plugins contribute ordered stable system sections, cache-safe dynamic context, tool schemas, and named variables. The loop assembles once per step, renders stable sections as the system prompt, and appends a durable full dynamic-context snapshot only when its text changes or compaction removed the retained snapshot. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
## Config
@@ -17,6 +17,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute cache-safe dynamic model context. Contexts are ordered independently from system sections; scoped contributions shadow same-named globals. The agent loop materializes the complete current set as one sourced user-role snapshot after retained history, only when changed or missing. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer 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: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
@@ -29,14 +30,17 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection``{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100199`.
- `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.
- `PromptContext``{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections.
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section and context 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.
- `renderContextSnapshot(assembly)` — applies the same strict interpolation to contexts, drops empty entries, and emits one full snapshot with an explicit supersession statement. An empty active set returns `''`; the loop emits one clearing snapshot when previously visible context disappears.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
### Extension points
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Context providers: policy and other changing-state owners contribute complete current facts without mutating the stable system prompt.
- 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](#live-events): cooperatively mutate or replace the assembly per caller.
@@ -65,6 +69,20 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r
Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token.
### Dynamic runtime context
#### What the model sees
Active contexts are joined in deterministic order after strict interpolation and logged as one sourced user-role message immediately before the request that first needs that snapshot. The message begins `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` A changed snapshot is appended after retained history; an unchanged retained snapshot adds nothing. If compaction removes it, the current full snapshot is emitted again. Removing the last context emits one explicit clearing snapshot.
#### Token effect
One concise message on the first request, on an effective context change, after compaction removed the retained snapshot, or when the active set becomes empty. Unchanged steps add no duplicate tokens.
#### KV Cache effect
Append-only after retained history. A context change preserves the previously cached system and conversation prefix instead of rewriting the first wire message.
### Tool schemas
#### What the model sees

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
系统提示词组装注册表。插件贡献有序、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整的模型提示词。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
模型输入组装注册表。插件贡献有序且稳定的系统段、缓存安全的动态上下文、工具 schema 和具名变量。循环在每个步骤组装一次,将稳定段渲染为系统提示词并且仅在文本变化或压缩compaction移除了保留的快照时追加一份持久的完整动态上下文快照。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
## 配置
@@ -17,6 +17,7 @@
### 公开 API
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose资源释放
- `ctx.systemPrompt.context(context: PromptContext): () => void`贡献缓存安全的动态模型上下文。上下文与系统段分别排序带作用域的贡献会遮蔽同名全局项。仅在完整当前集合变化或缺失时agent loop智能体循环会在保留的历史后将其具体化为一份带来源的 user 角色快照。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }``schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
@@ -29,15 +30,18 @@
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
- `PromptSection``{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona工具引导使用 `100199`
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输
- `PromptContext``{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}``{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
- `renderContextSnapshot(assembly)`:对上下文执行同样严格的插值,删除空条目,并发出一份带显式取代声明的完整快照。活动集合为空时返回 `''`;先前可见的上下文消失时,循环会发出一份清除快照。
可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly``AssembleContext` 声明额外字段。
### 扩展点
- 段提供方工具包package拥有跨调用引导`tool:bash``tool:read` 等);此插件拥有 `harness:identity``deployment:persona`
- 变量提供方:agent loop智能体循环注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)
- 上下文提供方:策略及其他变化状态的归属方贡献完整的当前事实,而不改变稳定的系统提示词
- 变量提供方agent loop 注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
@@ -65,6 +69,20 @@ You are an AI agent powered by the DeepSeek Harness SDK.
只要身份、persona、变量、段文本与顺序的渲染完全相同前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。
### 动态运行时上下文
#### 模型看到的内容
活动上下文经过严格插值后按确定顺序连接,并在首次需要该快照的请求之前立即记录为一条带来源的 user 角色消息。消息以 `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` 开头。变化后的快照会追加到保留的历史之后;保留的快照未变时不会增加内容。如果压缩移除了它,当前完整快照会再次发出。移除最后一项上下文时会发出一份显式清除快照。
#### Token 影响
首次请求、上下文实际变化、压缩移除保留的快照或活动集合变空时,会增加一条简洁消息。未变化的步骤不会增加重复 token。
#### KV Cache 影响
在保留的历史之后仅追加。上下文变化会保留先前缓存的系统与对话前缀,而不会改写第一条 wire 消息。
### 工具 schema
#### 模型看到的内容

View File

@@ -1,5 +1,5 @@
/**
* Registry for ordered prompt sections, tool schemas, and prompt variables.
* Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -17,7 +17,7 @@ declare module 'cordis' {
interface Events {
/**
* Expert waterfall over the assembled sections, tools, and variables.
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
@@ -65,6 +65,20 @@ export interface PromptSection {
readonly text: string | ((context: AssembleContext) => string)
}
/**
* One dynamic model-context contribution. Unlike a {@link PromptSection}, its
* rendered text is materialized as a durable user-role snapshot at the request
* tail, so changing runtime state preserves the stable system/history prefix.
*/
export interface PromptContext {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
readonly name: string
/** Contexts are joined in ascending order, independently of system-section order. */
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly 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. */
@@ -73,6 +87,14 @@ export interface AssembledSection {
text: string
}
/** One dynamic context contribution with its text resolved. */
export interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved (but not yet interpolated) context text. */
text: string
}
/** Tool schemas visible in one assembly and their pre-restriction name set. */
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
@@ -82,11 +104,13 @@ export interface ToolProviderResult {
}
/**
* Merge-extensible assembled prompt. Sections remain uninterpolated until
* {@link renderPrompt}; tools are already in canonical model-facing order.
* Merge-extensible assembled model input. Sections and contexts remain
* uninterpolated until their renderers; tools are already in canonical
* model-facing order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
@@ -170,14 +194,35 @@ export interface Config {
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables))
.map(section => interpolate(section, assembly.variables, 'section'))
.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
/**
* Render the complete current dynamic context snapshot. The agent loop appends
* a new durable snapshot only when this text changes or is no longer retained
* after compaction; the explicit supersession clause makes older snapshots in
* history harmless.
* @param assembly - the assembly whose contexts and variables to render.
* @returns the current full snapshot, or `''` when no context is active.
*/
export function renderContextSnapshot(assembly: PromptAssembly): string {
const body = assembly.contexts
.map(context => interpolate(context, assembly.variables, 'context'))
.filter(text => text.length > 0)
.join('\n\n')
if (body.length === 0) return ''
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
}
/** Interpolate one section or context and attribute diagnostics to its owning input. */
function interpolate(
input: AssembledSection | AssembledContext,
variables: Record<string, string | undefined>,
kind: 'section' | 'context',
): string {
const text = input.text
let result = ''
let last = 0
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
@@ -185,7 +230,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
if (group === null) {
// A later closing brace makes this malformed; otherwise it is literal prose.
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)`)
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
}
result += text.slice(last, open + 2)
last = open + 2
@@ -194,16 +239,16 @@ function interpolate(section: AssembledSection, variables: Record<string, string
// `{{}}` yields an empty name and follows the malformed-reference 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)})`)
throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)
}
// Do not resolve unregistered names through Object.prototype.
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)'}`)
throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.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}")`)
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)
}
result += text.slice(last, open) + value
last = open + group[0].length
@@ -220,6 +265,7 @@ type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly contexts: NamedEntries<PromptContext>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
@@ -231,6 +277,9 @@ class PromptLayer implements ScopeLayer {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.contexts = new NamedEntries(name => new Error(scope === undefined
? `prompt context "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt context "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
@@ -239,6 +288,7 @@ class PromptLayer implements ScopeLayer {
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.contexts.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
@@ -297,6 +347,25 @@ export class SystemPrompt extends Service {
)
}
/**
* Register ordered cache-safe dynamic context in the calling context's scope.
* A scoped context shadows a global context with the same name; duplicates
* within one layer and non-finite orders throw. Registration and disposal
* emit `system-prompt/change`.
* @param context - the context contribution to register.
* @returns the exact Cordis effect disposer.
*/
context(context: PromptContext): () => void {
if (!Number.isFinite(context.order)) {
throw new TypeError(`prompt context "${context.name}" order must be a finite number`)
}
return this.layers.effect(
this.ctx,
layer => layer.contexts.insert(context.name, context),
{ label: 'systemPrompt.context()' },
)
}
/**
* Register a tool-schema provider in the calling context's scope. Global and
* matching scoped providers both contribute; returning the reserved
@@ -352,6 +421,7 @@ export class SystemPrompt extends Service {
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = this.layers.merge(scope, layer => layer.sections)
const contextByName = this.layers.merge(scope, layer => layer.contexts)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.layers.global.toolProviders.values(),
@@ -377,6 +447,12 @@ export class SystemPrompt extends Service {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
})),
contexts: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}

View File

@@ -22,6 +22,14 @@ function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): voi
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
}
const contextNames = new Set<string>()
for (const context of assembly.contexts) {
if (context.name.length === 0) fail('assembled context names must be non-empty')
if (contextNames.has(context.name)) fail(`assembled context name ${JSON.stringify(context.name)} is duplicated`)
contextNames.add(context.name)
if (typeof context.text !== 'string') fail(`assembled context ${JSON.stringify(context.name)} text must be a string`)
}
for (const tool of assembly.tools) {
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
}

View File

@@ -13,6 +13,7 @@ async function setup(): Promise<Context> {
const valid = (): PromptAssembly => ({
sections: [{ name: 'identity', text: 'prompt' }],
contexts: [{ name: 'policy', text: 'current policy' }],
tools: [{ name: 'echo', description: 'Echo', parameters: {} }],
variables: { cwd: '/repo', optional: undefined },
})
@@ -34,6 +35,9 @@ describe('system-prompt invariants', () => {
[{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/],
[{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/],
[{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/],
[{ ...valid(), contexts: [{ name: '', text: 'x' }] }, /context names must be non-empty/],
[{ ...valid(), contexts: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /context name "x" is duplicated/],
[{ ...valid(), contexts: [{ name: 'x', text: 1 as never }] }, /context "x" text must be a string/],
[{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/],
[{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/],
[{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/],

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
async function mount(config: Config = {}): Promise<Context> {
@@ -125,6 +125,25 @@ describe('scoped variables', () => {
})
})
describe('scoped cache-safe context', () => {
it('shadows a global context for one scope and cleans up with that scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child-context')
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'global policy' })
scope.ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'scoped policy' })
expect(() => scope.ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'duplicate' }))
.toThrow('prompt context "policy" is already registered in this scope')
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain('scoped policy')
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toContain('global policy')
await scope.dispose()
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain('global policy')
})
})
describe('scoped tool providers and toolOrder × restriction', () => {
it('scoped providers are consulted only for their scope', async () => {
const ctx = await mount()

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
/**
* Every assembly carries the plugin's own built-ins — `harness:identity`
@@ -64,14 +64,21 @@ describe('SystemPrompt', () => {
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
ctx.systemPrompt.context({ name: 'later', order: 20, text: () => 'context 2' })
ctx.systemPrompt.context({ name: 'earlier', order: 10, text: 'context 1' })
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
const assembly = await ctx.systemPrompt.assemble()
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.contexts).toEqual([
{ name: 'earlier', text: 'context 1' },
{ name: 'later', text: 'context 2' },
])
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
expect(assembly.variables).toEqual({})
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
expect(renderContextSnapshot(assembly)).toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncontext 1\n\ncontext 2')
})
it('resolves section text providers against the assemble context, at each assemble call', async () => {
@@ -96,16 +103,19 @@ describe('SystemPrompt', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
inner.systemPrompt.context({ name: 'scoped-context', order: 0, text: 'scoped context' })
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
inner.systemPrompt.variable('scoped_var', () => 'v')
}, { inject: ['systemPrompt'] }))
const before = await ctx.systemPrompt.assemble()
expect(contributed(before)).toHaveLength(1)
expect(before.contexts).toHaveLength(1)
expect(before.variables).toEqual({ scoped_var: 'v' })
await fiber.dispose()
const assembly = await ctx.systemPrompt.assemble()
expect(contributed(assembly)).toHaveLength(0)
expect(assembly.contexts).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)
@@ -131,6 +141,17 @@ describe('SystemPrompt', () => {
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
})
it('rejects duplicate and non-finite context registrations without leaking', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'first' })
expect(() => ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'second' }))
.toThrow('prompt context "policy" is already registered')
expect(() => ctx.systemPrompt.context({ name: 'bad', order: Number.NaN, text: 'x' }))
.toThrow('prompt context "bad" order must be a finite number')
expect((await ctx.systemPrompt.assemble()).contexts).toEqual([{ name: 'policy', text: 'first' }])
})
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -236,7 +257,7 @@ describe('SystemPrompt', () => {
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
ctx.on('system-prompt/assemble', async () => {
return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
return { sections: [], contexts: [], tools: [], variables: {} } satisfies PromptAssembly
})
const assembly = await ctx.systemPrompt.assemble()
@@ -252,6 +273,7 @@ describe('SystemPrompt', () => {
const first = await ctx.systemPrompt.assemble()
first.sections[0]!.name = 'mutated'
first.sections[0]!.text = 'mutated'
first.contexts.push({ name: 'mutated', text: 'mutated' })
first.tools[0]!.description = 'mutated'
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
firstParameters.properties['leak'] = { type: 'string' }
@@ -259,6 +281,7 @@ describe('SystemPrompt', () => {
const second = await ctx.systemPrompt.assemble()
expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
expect(second.sections[0]!.text).toBe(IDENTITY)
expect(second.contexts).toEqual([])
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
})
@@ -268,12 +291,33 @@ describe('SystemPrompt', () => {
{ name: 'empty', text: '' },
{ name: 'real', text: 'content' },
],
contexts: [],
tools: [],
variables: {},
})
expect(result).toBe('content')
})
it('filters empty context, interpolates variables, and returns empty without active context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.context({ name: 'empty', order: 0, text: '' })
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toBe('')
ctx.systemPrompt.variable('mode', () => 'read-only')
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'Mode: {{mode}}.' })
expect(renderContextSnapshot(await ctx.systemPrompt.assemble()))
.toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.')
})
it('attributes context interpolation failures to the contributing context', () => {
expect(() => renderContextSnapshot({
sections: [],
contexts: [{ name: 'policy', text: 'Mode: {{missing}}.' }],
tools: [],
variables: {},
})).toThrow('unknown prompt variable "{{missing}}" in context "policy"; registered variables: (none)')
})
it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -290,6 +334,17 @@ describe('SystemPrompt', () => {
expect(changeCount).toBe(2)
})
it('emits system-prompt/change when a context is registered and disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let changeCount = 0
ctx.on('system-prompt/change', () => void changeCount++)
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'current' })
expect(changeCount).toBe(1)
dispose()
expect(changeCount).toBe(2)
})
it('cleans up tool providers on fiber dispose', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -404,13 +459,14 @@ describe('SystemPrompt', () => {
})
it('names "(none)" when no variables are registered at all', () => {
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], contexts: [], 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', text: 'in {{cwd}}' }],
contexts: [],
tools: [],
variables: { cwd: undefined },
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
@@ -419,6 +475,7 @@ describe('SystemPrompt', () => {
it('throws on a malformed complete reference, e.g. inner spaces', () => {
expect(() => renderPrompt({
sections: [{ name: 's', text: 'on {{ model }}' }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
@@ -427,6 +484,7 @@ describe('SystemPrompt', () => {
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
const text = renderPrompt({
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
contexts: [],
tools: [],
variables: {},
})
@@ -439,6 +497,7 @@ describe('SystemPrompt', () => {
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
expect(() => renderPrompt({
sections: [{ name: 's', text }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference at')
@@ -449,6 +508,7 @@ describe('SystemPrompt', () => {
// source into the prompt; Object.hasOwn must reject it instead.
expect(() => renderPrompt({
sections: [{ name: 's', text: 'on {{constructor}}' }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('unknown prompt variable "{{constructor}}"')
@@ -465,6 +525,7 @@ describe('SystemPrompt', () => {
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
const text = renderPrompt({
sections: [{ name: 's', text: 'v = {{model}}!' }],
contexts: [],
tools: [],
variables: { model: 'literal {{sneaky}} inside' },
})