2026-06-11 12:39:27 +08:00
/**
2026-07-13 16:24:32 +08:00
* Registry for ordered prompt sections, tool schemas, and prompt variables.
2026-07-05 23:23:46 +08:00
*
2026-06-11 12:39:27 +08:00
* @module @deepseek-ai/dsh-system-prompt
*/
2026-06-11 10:54:06 +08:00
import { Context , Service } from 'cordis'
2026-07-05 23:23:46 +08:00
import z from 'schemastery'
2026-07-21 22:14:49 +08:00
import { AnonymousEntries , NamedEntries , ScopedLayers , scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey , ScopeLayer , Scoped } from '@deepseek-ai/dsh-scope'
2026-06-11 10:54:06 +08:00
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
interface Context {
systemPrompt : SystemPrompt
}
interface Events {
2026-06-20 19:21:42 +08:00
/**
2026-07-13 16:24:32 +08:00
* Expert waterfall over the assembled sections, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
2026-07-16 18:12:34 +08:00
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns.
2026-07-13 16:24:32 +08:00
* @param assembly - the mutable assembly built from registered providers.
* @param context - the caller's per-assembly context.
2026-06-20 19:21:42 +08:00
* @mode waterfall
*/
2026-07-09 01:09:21 +08:00
'system-prompt/assemble' ( this : Scoped < SystemPrompt > , assembly : PromptAssembly , context : AssembleContext , next : ( ) = > Promise < PromptAssembly > ) : Promise < PromptAssembly >
2026-06-20 19:21:42 +08:00
/**
2026-07-13 16:24:32 +08:00
* Emitted when any prompt provider changes. This registry notification is
* unfiltered because a global change affects every scope.
2026-06-20 19:21:42 +08:00
* @mode emit
*/
2026-06-11 10:54:06 +08:00
'system-prompt/change' ( ) : void
}
}
2026-07-13 16:24:32 +08:00
/** Merge-extensible context for one prompt assembly. */
2026-07-09 01:09:21 +08:00
export interface AssembleContext {
/**
2026-07-13 16:24:32 +08:00
* Scope whose providers and waterfall listeners participate. When absent,
* only global providers and subject-less listeners participate.
2026-07-09 01:09:21 +08:00
*/
scope? : ScopeKey
2026-07-16 18:12:34 +08:00
/** Explicit control signal for the turn that requested this assembly, when any. */
signal? : AbortSignal
2026-07-09 01:09:21 +08:00
}
2026-07-05 01:54:46 +08:00
/** One contributed section of the system prompt (registry input). */
2026-06-11 10:54:06 +08:00
export interface PromptSection {
2026-07-05 01:54:46 +08:00
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
2026-07-12 22:39:01 +08:00
readonly name : string
2026-07-05 01:54:46 +08:00
/**
2026-07-05 11:37:32 +08:00
* Sections are concatenated in ascending order. Convention: `-100` is the
2026-07-05 23:23:46 +08:00
* harness identity, `0` the deployment persona, tool guidance uses 100– 199;
2026-07-05 11:37:32 +08:00
* other negative orders also render before the persona.
2026-07-05 01:54:46 +08:00
*/
2026-07-12 22:39:01 +08:00
readonly order : number
2026-07-05 01:54:46 +08:00
/**
* 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}.
*/
2026-07-12 22:39:01 +08:00
readonly text : string | ( ( context : AssembleContext ) = > string )
2026-07-05 01:54:46 +08:00
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
export interface AssembledSection {
/** The contributing section's unique name. */
2026-06-11 10:54:06 +08:00
name : string
2026-07-05 01:54:46 +08:00
/** The resolved (but not yet interpolated) section text. */
text : string
2026-06-11 10:54:06 +08:00
}
2026-07-13 16:24:32 +08:00
/** Tool schemas visible in one assembly and their pre-restriction name set. */
2026-07-09 01:09:21 +08:00
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
2026-07-12 22:39:01 +08:00
readonly schemas : readonly ToolSchema [ ]
2026-07-09 01:09:21 +08:00
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
2026-07-12 22:39:01 +08:00
readonly knownNames? : readonly string [ ]
2026-07-11 22:55:26 +08:00
}
2026-06-11 10:54:06 +08:00
/**
2026-07-13 16:24:32 +08:00
* Merge-extensible assembled prompt. Sections remain uninterpolated until
* {@link renderPrompt}; tools are already in canonical model-facing order.
2026-06-11 10:54:06 +08:00
*/
export interface PromptAssembly {
2026-07-05 01:54:46 +08:00
sections : AssembledSection [ ]
2026-06-11 10:54:06 +08:00
tools : ToolSchema [ ]
2026-07-05 01:54:46 +08:00
variables : Record < string , string | undefined >
2026-06-11 10:54:06 +08:00
}
2026-07-05 01:54:46 +08:00
/** Valid variable names: how they are written between the braces. */
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
2026-07-05 02:42:48 +08:00
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
2026-07-05 01:54:46 +08:00
2026-07-13 16:24:32 +08:00
/** Reserved {@link Config.toolOrder} marker for unlisted tools. */
2026-07-07 20:18:01 +08:00
export const TOOL_ORDER_REST = '<unlisted-tools>'
2026-07-07 11:03:11 +08:00
/**
2026-07-13 16:24:32 +08:00
* Validate duplicate names and the required {@link TOOL_ORDER_REST} marker.
* Registered names are checked later because plugins have not loaded yet.
2026-07-07 11:03:11 +08:00
*/
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
}
/**
2026-07-13 16:24:32 +08:00
* Apply configured tool order, inserting unlisted tools lexicographically at
* {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted
* names may be absent.
2026-07-07 11:03:11 +08:00
*/
2026-07-09 01:09:21 +08:00
function orderTools ( tools : ToolSchema [ ] , toolOrder : string [ ] | undefined , knownNames : ReadonlySet < string > ) : ToolSchema [ ] {
2026-07-07 22:03:12 +08:00
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) ` )
}
2026-07-07 11:03:11 +08:00
if ( toolOrder === undefined ) return tools . sort ( compareToolNames )
2026-07-09 01:09:21 +08:00
const unknown = toolOrder . filter ( name = > name !== TOOL_ORDER_REST && ! knownNames . has ( name ) )
2026-07-07 20:20:56 +08:00
if ( unknown . length > 0 ) {
2026-07-09 01:09:21 +08:00
throw new Error ( ` toolOrder lists unregistered tool ${ unknown . length > 1 ? 's' : '' } ${ unknown . map ( name = > ` " ${ name } " ` ) . join ( ', ' ) } ; known tools: ${ [ . . . knownNames ] . sort ( ) . join ( ', ' ) || '(none)' } ` )
2026-07-07 20:20:56 +08:00
}
2026-07-07 11:03:11 +08:00
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
}
2026-07-06 22:09:30 +08:00
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
2026-07-05 23:23:46 +08:00
export interface Config {
/**
2026-07-13 16:24:32 +08:00
* Deployment-wide order-0 persona template. A scoped section named
* `deployment:persona` shadows it; `{{variable}}` references are strict.
2026-07-05 23:23:46 +08:00
*/
persona? : string
2026-07-07 11:03:11 +08:00
/**
2026-07-13 16:24:32 +08:00
* Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
2026-07-13 23:27:00 +08:00
* 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.
2026-07-07 11:03:11 +08:00
*/
toolOrder? : string [ ]
2026-07-05 23:23:46 +08:00
}
2026-07-05 01:54:46 +08:00
/**
2026-07-13 16:24:32 +08:00
* Interpolate strict `{{variable}}` references, drop empty sections, and join
* the rest with blank lines. Malformed, unknown, or undefined references throw;
2026-07-13 23:27:00 +08:00
* a lone `{{` without any later `}}` is literal prose, and substituted values
* are not scanned again.
2026-07-13 16:24:32 +08:00
* @param assembly - the assembly whose sections and variables to render.
* @returns the rendered prompt, or `''` when all sections are empty.
2026-07-05 01:54:46 +08:00
*/
2026-06-11 10:54:06 +08:00
export function renderPrompt ( assembly : PromptAssembly ) : string {
return assembly . sections
2026-07-05 01:54:46 +08:00
. map ( section = > interpolate ( section , assembly . variables ) )
2026-06-11 10:54:06 +08:00
. filter ( text = > text . length > 0 )
. join ( '\n\n' )
}
2026-07-05 01:54:46 +08:00
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
function interpolate ( section : AssembledSection , variables : Record < string , string | undefined > ) : string {
2026-07-05 02:42:48 +08:00
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 ) {
2026-07-13 16:24:32 +08:00
// A later closing brace makes this malformed; otherwise it is literal prose.
2026-07-05 02:42:48 +08:00
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
}
2026-07-13 16:24:32 +08:00
// `{{}}` yields an empty name and follows the malformed-reference path.
2026-07-05 02:42:48 +08:00
const name = group [ 0 ] . slice ( 2 , - 2 )
2026-07-05 01:54:46 +08:00
if ( ! VARIABLE_NAME . test ( name ) ) {
throw new Error ( ` malformed prompt variable reference "{{ ${ name } }}" in section " ${ section . name } " (variable names match ${ String ( VARIABLE_NAME ) } ) ` )
}
2026-07-13 16:24:32 +08:00
// Do not resolve unregistered names through Object.prototype.
2026-07-05 02:42:48 +08:00
if ( ! Object . hasOwn ( variables , name ) ) {
2026-07-05 01:54:46 +08:00
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 } ") ` )
}
2026-07-05 02:42:48 +08:00
result += text . slice ( last , open ) + value
last = open + group [ 0 ] . length
}
return result + text . slice ( last )
2026-07-05 01:54:46 +08:00
}
2026-07-21 22:14:49 +08:00
/** 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 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 . 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 . toolProviders . isEmpty ( )
&& this . variables . isEmpty ( )
}
}
2026-07-13 16:24:32 +08:00
/** Registry service for the prompt inputs assembled before each model step. */
2026-06-11 10:54:06 +08:00
export class SystemPrompt extends Service {
2026-07-05 23:23:46 +08:00
static Config : z < Config > = z . object ( {
persona : z.string ( ) . default ( '' ) ,
2026-07-13 16:24:32 +08:00
// Preserve omission because an explicit empty order lacks the rest marker.
2026-07-07 11:03:11 +08:00
toolOrder : z.array ( z . string ( ) ) . default ( undefined as unknown as string [ ] ) ,
2026-07-05 23:23:46 +08:00
} )
2026-07-21 22:14:49 +08:00
private readonly layers = new ScopedLayers (
scope = > new PromptLayer ( scope ) ,
( ) = > { this . ctx . emit ( 'system-prompt/change' ) } ,
)
2026-07-07 11:03:11 +08:00
private readonly toolOrder : string [ ] | undefined
2026-06-11 10:54:06 +08:00
2026-07-14 02:54:30 +08:00
constructor ( ctx : Context , config : Config ) {
2026-06-11 10:54:06 +08:00
super ( ctx , 'systemPrompt' )
2026-07-07 11:03:11 +08:00
this . toolOrder = validateToolOrder ( config . toolOrder )
2026-07-13 16:24:32 +08:00
// Keep harness-owned openers independent of the selected loop plugin.
2026-07-05 23:23:46 +08:00
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 ,
2026-07-13 16:24:32 +08:00
// The fallback narrows the optional input type; the schema already defaults it.
2026-07-05 23:23:46 +08:00
text : config.persona ? ? '' ,
} )
2026-06-11 10:54:06 +08:00
}
2026-06-11 12:39:27 +08:00
/**
2026-07-13 16:24:32 +08:00
* 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
2026-07-13 23:27:00 +08:00
* layer and non-finite orders throw. Registration and disposal emit
* `system-prompt/change`.
2026-07-13 16:24:32 +08:00
* @param section - the section to register.
* @returns the exact Cordis effect disposer.
2026-06-11 12:39:27 +08:00
*/
2026-07-13 11:58:55 +08:00
section ( section : PromptSection ) : ( ) = > void {
2026-07-12 22:39:01 +08:00
if ( ! Number . isFinite ( section . order ) ) {
throw new TypeError ( ` prompt section " ${ section . name } " order must be a finite number ` )
2026-07-12 05:13:17 +08:00
}
2026-07-21 22:14:49 +08:00
return this . layers . effect (
this . ctx ,
layer = > layer . sections . insert ( section . name , section ) ,
{ label : 'systemPrompt.section()' } ,
)
2026-06-11 10:54:06 +08:00
}
2026-06-11 12:39:27 +08:00
/**
2026-07-13 23:27:00 +08:00
* 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.
2026-07-13 16:24:32 +08:00
* @returns the exact Cordis effect disposer.
2026-06-11 12:39:27 +08:00
*/
2026-07-13 11:58:55 +08:00
tools ( provider : ( context : AssembleContext ) = > ToolProviderResult ) : ( ) = > void {
2026-07-21 22:14:49 +08:00
return this . layers . effect (
this . ctx ,
layer = > layer . toolProviders . append ( provider ) ,
{ label : 'systemPrompt.tools()' } ,
)
2026-06-11 10:54:06 +08:00
}
2026-06-11 12:39:27 +08:00
/**
2026-07-13 16:24:32 +08:00
* Register a prompt variable in the calling context's scope. Scoped values
2026-07-13 23:27:00 +08:00
* shadow globals; invalid or duplicate names throw. A provider may return
* `undefined`, but rendering a section that references that value then fails.
2026-07-13 16:24:32 +08:00
* @param name - the `[a-z][a-z0-9_]*` reference name.
* @param provider - evaluated for each assembly.
* @returns the exact Cordis effect disposer.
2026-07-05 01:54:46 +08:00
*/
2026-07-13 11:58:55 +08:00
variable ( name : string , provider : ( context : AssembleContext ) = > string | undefined ) : ( ) = > void {
2026-07-12 22:39:01 +08:00
if ( ! VARIABLE_NAME . test ( name ) ) {
throw new Error ( ` invalid prompt variable name " ${ name } " (must match ${ String ( VARIABLE_NAME ) } ) ` )
2026-07-12 05:13:17 +08:00
}
2026-07-21 22:14:49 +08:00
return this . layers . effect (
this . ctx ,
layer = > layer . variables . insert ( name , provider ) ,
{ label : 'systemPrompt.variable()' } ,
)
2026-07-05 01:54:46 +08:00
}
/**
2026-07-13 23:27:00 +08:00
* 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.
2026-07-13 16:24:32 +08:00
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the authoritative post-waterfall assembly.
2026-06-11 12:39:27 +08:00
*/
2026-07-13 16:24:32 +08:00
// Keep configuration failures on the declared asynchronous error path.
2026-07-07 20:20:56 +08:00
async assemble ( context : AssembleContext = { } ) : Promise < PromptAssembly > {
2026-07-09 01:09:21 +08:00
const scope = context . scope
2026-07-13 16:24:32 +08:00
// Scoped variables shadow globals.
2026-07-05 01:54:46 +08:00
const variables : Record < string , string | undefined > = { }
2026-07-21 22:14:49 +08:00
for ( const [ name , provider ] of this . layers . global . variables . entries ( ) ) {
2026-07-05 01:54:46 +08:00
variables [ name ] = provider ( context )
}
2026-07-21 22:14:49 +08:00
const scopedVariables = this . layers . peek ( scope ) ? . variables
for ( const [ name , provider ] of scopedVariables ? . entries ( ) ? ? [ ] ) {
2026-07-09 01:09:21 +08:00
variables [ name ] = provider ( context )
}
2026-07-13 16:24:32 +08:00
// Scoped sections shadow globals before the stable order sort.
2026-07-21 22:14:49 +08:00
const sectionByName = this . layers . merge ( scope , layer = > layer . sections )
2026-07-13 16:24:32 +08:00
// Validate order against pre-restriction names while collecting visible schemas.
2026-07-09 01:09:21 +08:00
const providers = [
2026-07-21 22:14:49 +08:00
. . . this . layers . global . toolProviders . values ( ) ,
. . . ( this . layers . peek ( scope ) ? . toolProviders . values ( ) ? ? [ ] ) ,
2026-07-09 01:09:21 +08:00
]
const collected : ToolSchema [ ] = [ ]
const knownNames = new Set < string > ( )
for ( const provider of providers ) {
const result = provider ( context )
2026-07-12 22:39:01 +08:00
const schemas = result . schemas . map ( ( { name , description , parameters } ) : ToolSchema = > ( {
name ,
description ,
parameters : structuredClone ( parameters ) ,
} ) )
const acceptedKnownNames = result . knownNames ? ? schemas . map ( tool = > tool . name )
2026-07-12 03:51:55 +08:00
collected . push ( . . . schemas )
for ( const name of acceptedKnownNames ) knownNames . add ( name )
2026-07-09 01:09:21 +08:00
}
2026-06-11 10:54:06 +08:00
const assembly : PromptAssembly = {
2026-07-09 01:09:21 +08:00
sections : [ . . . sectionByName . values ( ) ]
2026-07-14 04:41:23 +08:00
. sort ( ( a , b ) = > a . order - b . order )
2026-07-05 01:54:46 +08:00
. map ( section = > ( {
name : section.name ,
text : typeof section . text === 'function' ? section . text ( context ) : section . text ,
2026-07-14 04:41:23 +08:00
} ) ) ,
2026-07-09 01:09:21 +08:00
tools : orderTools ( collected , this . toolOrder , knownNames ) ,
2026-07-05 01:54:46 +08:00
variables ,
2026-06-11 10:54:06 +08:00
}
2026-07-13 13:09:41 +08:00
return this . ctx . waterfall (
2026-07-11 22:55:26 +08:00
scopeTarget ( this , scope ) , 'system-prompt/assemble' , assembly , context ,
( ) = > Promise . resolve ( assembly ) ,
)
2026-06-11 10:54:06 +08:00
}
}
export default SystemPrompt