| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491 |
- /**
- * Registry for ordered system sections, dynamic context, tool schemas, and prompt variables.
- *
- * @module @deepseek-ai/dsh-system-prompt
- */
- import { Context, Service } from 'cordis'
- import z from 'schemastery'
- import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
- import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
- import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm'
- declare module 'cordis' {
- interface Context {
- systemPrompt: SystemPrompt
- }
- interface Events {
- /**
- * 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
- * be retained to control later turns.
- * @param assembly - the mutable assembly built from registered providers.
- * @param context - the caller's per-assembly context.
- * @mode waterfall
- */
- 'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
- /**
- * Emitted when any prompt provider changes. This registry notification is
- * unfiltered because a global change affects every scope.
- * @mode emit
- */
- 'system-prompt/change'(): void
- }
- }
- /** Merge-extensible context for one prompt assembly. */
- export interface AssembleContext {
- /**
- * Scope whose providers and waterfall listeners participate. When absent,
- * only global providers and subject-less listeners participate.
- */
- scope?: ScopeKey
- /** Explicit control signal for the turn that requested this assembly, when any. */
- signal?: AbortSignal
- }
- /** One contributed section of the system prompt (registry input). */
- export interface PromptSection {
- /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
- readonly name: string
- /**
- * 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.
- */
- readonly order: number
- /**
- * 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}.
- */
- readonly text: string | ((context: AssembleContext) => string)
- }
- /** Dynamic model context materialized as a durable user-role snapshot. */
- export interface PromptContext {
- /** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
- readonly name: string
- /** Contexts are joined in ascending 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. */
- name: string
- /** The resolved (but not yet interpolated) section text. */
- text: string
- }
- /** One resolved dynamic context contribution. */
- export interface AssembledContext {
- /** The contributing context's unique name. */
- name: string
- /** The resolved text before variable interpolation. */
- 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. */
- readonly schemas: readonly ToolSchema[]
- /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
- readonly knownNames?: readonly string[]
- }
- /**
- * Merge-extensible assembled model input. Sections and contexts remain
- * uninterpolated until rendered; tools are already in canonical order.
- */
- export interface PromptAssembly {
- sections: AssembledSection[]
- contexts: AssembledContext[]
- tools: ToolSchema[]
- variables: Record<string, string | undefined>
- }
- /**
- * The deployment persona's section name and order. Exported because a
- * composition can replace this slot — an agent preset shadows the
- * deployment's persona with its own — and both sides naming the same section
- * is what makes the replacement work rather than duplicate.
- */
- export const PERSONA_SECTION = 'deployment:persona'
- /** Prompt order of the persona slot; the first section a model reads. */
- export const PERSONA_ORDER = 0
- /** 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 = /^\{\{([^{}]*)\}\}/
- /** Reserved {@link Config.toolOrder} marker for unlisted tools. */
- export const TOOL_ORDER_REST = '<unlisted-tools>'
- /**
- * Validate duplicate names and the required {@link TOOL_ORDER_REST} marker.
- * Registered names are checked later because plugins have not loaded yet.
- */
- function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
- if (toolOrder === undefined) return undefined
- const seen = new Set<string>()
- for (const name of toolOrder) {
- if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
- seen.add(name)
- }
- if (!seen.has(TOOL_ORDER_REST)) {
- throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
- }
- return toolOrder
- }
- /**
- * Apply configured tool order, inserting unlisted tools lexicographically at
- * {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted
- * names may be absent.
- */
- function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
- const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
- if (reserved !== undefined) {
- throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
- }
- if (toolOrder === undefined) return tools.sort(compareToolNames)
- const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
- if (unknown.length > 0) {
- throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`)
- }
- const listed = new Set(toolOrder)
- const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
- return toolOrder.flatMap(name =>
- name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
- }
- /** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
- function compareToolNames(a: ToolSchema, b: ToolSchema): number {
- return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
- }
- /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
- export interface Config {
- /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
- includeHarnessIdentity?: boolean
- /**
- * Deployment-wide order-0 persona template. A scoped section named
- * `deployment:persona` shadows it; `{{variable}}` references are strict.
- */
- persona?: string
- /**
- * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
- * Shape errors fail at load and unknown names fail at assembly; known names
- * hidden in one scope may be absent there. Omitted means lexicographic order.
- */
- toolOrder?: string[]
- }
- /**
- * Interpolate strict `{{variable}}` references, drop empty sections, and join
- * the rest with blank lines. Malformed, unknown, or undefined references throw;
- * a lone `{{` without any later `}}` is literal prose, and substituted values
- * are not scanned again.
- * @param assembly - the assembly whose sections and variables to render.
- * @returns the rendered prompt, or `''` when all sections are empty.
- */
- export function renderPrompt(assembly: PromptAssembly): string {
- return assembly.sections
- .map(section => interpolate(section, assembly.variables, 'section'))
- .filter(text => text.length > 0)
- .join('\n\n')
- }
- /**
- * Render the complete dynamic context snapshot.
- * @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 {
- return joinContextSections(renderContextSections(assembly))
- }
- /**
- * The model-facing snapshot text for an already-rendered section list.
- *
- * A caller that also needs the sections renders them once and joins here, so a
- * request does not interpolate every context twice.
- * @param sections - sections from {@link renderContextSections}.
- * @returns the current full snapshot, or `''` when no context is active.
- */
- export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {
- const body = sections.map(section => section.text).join('\n\n')
- if (body.length === 0) return ''
- return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
- }
- /**
- * The same snapshot, kept as the named contributions it was assembled from.
- *
- * {@link renderContextSnapshot} joins these for the model; a consumer that
- * presents the snapshot uses them to attribute each part to the subsystem that
- * contributed it, without re-splitting the joined prose.
- * @param assembly - the assembly whose contexts and variables to render.
- * @returns one entry per contributing context that rendered to non-empty text.
- */
- export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] {
- return assembly.contexts
- .map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') }))
- .filter(section => section.text.length > 0)
- }
- /** 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)) {
- const group = GROUP_AT.exec(text.slice(open))
- 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 ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
- }
- result += text.slice(last, open + 2)
- last = open + 2
- continue
- }
- // `{{}}` 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 ${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 ${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 (${kind} "${input.name}")`)
- }
- result += text.slice(last, open) + value
- last = open + group[0].length
- }
- return result + text.slice(last)
- }
- /** One tool-schema provider stored in a prompt layer. */
- type ToolProvider = (context: AssembleContext) => ToolProviderResult
- /** One prompt-variable provider stored in a prompt layer. */
- 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>
- /**
- * Create one prompt layer with diagnostics specific to its ownership scope.
- * @param scope - the scoped owner, or `undefined` for global registrations.
- */
- constructor(scope: ScopeKey | undefined) {
- 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`))
- }
- /** @returns whether this layer owns no prompt registrations. */
- isEmpty(): boolean {
- return this.sections.isEmpty()
- && this.contexts.isEmpty()
- && this.toolProviders.isEmpty()
- && this.variables.isEmpty()
- }
- }
- /** Registry service for the prompt inputs assembled before each model step. */
- export class SystemPrompt extends Service {
- static Config: z<Config> = z.object({
- includeHarnessIdentity: z.boolean().default(true),
- persona: z.string().default(''),
- // Preserve omission because an explicit empty order lacks the rest marker.
- toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
- })
- private readonly layers = new ScopedLayers(
- scope => new PromptLayer(scope),
- () => { this.ctx.emit('system-prompt/change') },
- )
- private readonly toolOrder: string[] | undefined
- constructor(ctx: Context, config: Config) {
- super(ctx, 'systemPrompt')
- this.toolOrder = validateToolOrder(config.toolOrder)
- // Keep harness-owned openers independent of the selected loop plugin.
- if (config.includeHarnessIdentity ?? true) {
- this.section({
- name: 'harness:identity',
- order: -100,
- text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
- })
- }
- this.section({
- name: PERSONA_SECTION,
- order: PERSONA_ORDER,
- // The fallback narrows the optional input type; the schema already defaults it.
- text: config.persona ?? '',
- })
- }
- /**
- * Register an ordered prompt section in the calling context's scope. A scoped
- * section shadows a global section with the same name; duplicates within one
- * layer and non-finite orders throw. Registration and disposal emit
- * `system-prompt/change`.
- * @param section - the section to register.
- * @returns the exact Cordis effect disposer.
- */
- section(section: PromptSection): () => void {
- if (!Number.isFinite(section.order)) {
- throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
- }
- return this.layers.effect(
- this.ctx,
- layer => layer.sections.insert(section.name, section),
- { label: 'systemPrompt.section()' },
- )
- }
- /**
- * Register ordered dynamic context in the calling context's scope. Scoped
- * entries shadow global entries with the same name.
- * @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
- * {@link TOOL_ORDER_REST} name makes assembly fail.
- * @param provider - evaluated for each assembly with its context.
- * @returns the exact Cordis effect disposer.
- */
- tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
- return this.layers.effect(
- this.ctx,
- layer => layer.toolProviders.append(provider),
- { label: 'systemPrompt.tools()' },
- )
- }
- /**
- * Register a prompt variable in the calling context's scope. Scoped values
- * shadow globals; invalid or duplicate names throw. A provider may return
- * `undefined`, but rendering a section that references that value then fails.
- * @param name - the `[a-z][a-z0-9_]*` reference name.
- * @param provider - evaluated for each assembly.
- * @returns the exact Cordis effect disposer.
- */
- variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
- if (!VARIABLE_NAME.test(name)) {
- throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
- }
- return this.layers.effect(
- this.ctx,
- layer => layer.variables.insert(name, provider),
- { label: 'systemPrompt.variable()' },
- )
- }
- /**
- * Assemble global and scoped providers, detach tool parameters, apply
- * canonical ordering, then run the assembly waterfall. Scoped sections and
- * variables shadow globals; the returned waterfall value is authoritative.
- * @param context - the optional scope and plugin-defined assembly fields.
- * @returns the authoritative post-waterfall assembly.
- */
- // Keep configuration failures on the declared asynchronous error path.
- async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
- const scope = context.scope
- // Scoped variables shadow globals.
- const variables: Record<string, string | undefined> = {}
- for (const [name, provider] of this.layers.global.variables.entries()) {
- variables[name] = provider(context)
- }
- const scopedVariables = this.layers.peek(scope)?.variables
- for (const [name, provider] of scopedVariables?.entries() ?? []) {
- variables[name] = provider(context)
- }
- // 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(),
- ...(this.layers.peek(scope)?.toolProviders.values() ?? []),
- ]
- const collected: ToolSchema[] = []
- const knownNames = new Set<string>()
- for (const provider of providers) {
- const result = provider(context)
- const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
- name,
- description,
- parameters: structuredClone(parameters),
- }))
- const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
- collected.push(...schemas)
- for (const name of acceptedKnownNames) knownNames.add(name)
- }
- const assembly: PromptAssembly = {
- sections: [...sectionByName.values()]
- .sort((a, b) => a.order - b.order)
- .map(section => ({
- 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,
- }
- return this.ctx.waterfall(
- scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
- () => Promise.resolve(assembly),
- )
- }
- }
- export default SystemPrompt
|