index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. /**
  2. * Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables.
  3. *
  4. * @module @deepseek-ai/dsh-system-prompt
  5. */
  6. import { Context, Service } from 'cordis'
  7. import z from 'schemastery'
  8. import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
  9. import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
  10. import type { ToolSchema } from '@deepseek-ai/dsh-llm'
  11. declare module 'cordis' {
  12. interface Context {
  13. systemPrompt: SystemPrompt
  14. }
  15. interface Events {
  16. /**
  17. * Expert waterfall over the assembled sections, contexts, tools, and variables.
  18. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
  19. * receive only that scope's assemblies. The returned value is authoritative.
  20. * A supplied signal controls only this explicit assembly request and must not
  21. * be retained to control later turns.
  22. * @param assembly - the mutable assembly built from registered providers.
  23. * @param context - the caller's per-assembly context.
  24. * @mode waterfall
  25. */
  26. 'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
  27. /**
  28. * Emitted when any prompt provider changes. This registry notification is
  29. * unfiltered because a global change affects every scope.
  30. * @mode emit
  31. */
  32. 'system-prompt/change'(): void
  33. }
  34. }
  35. /** Merge-extensible context for one prompt assembly. */
  36. export interface AssembleContext {
  37. /**
  38. * Scope whose providers and waterfall listeners participate. When absent,
  39. * only global providers and subject-less listeners participate.
  40. */
  41. scope?: ScopeKey
  42. /** Explicit control signal for the turn that requested this assembly, when any. */
  43. signal?: AbortSignal
  44. }
  45. /** One contributed section of the system prompt (registry input). */
  46. export interface PromptSection {
  47. /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
  48. readonly name: string
  49. /**
  50. * Sections are concatenated in ascending order. Convention: `-100` is the
  51. * harness identity, `0` the deployment persona, tool guidance uses 100–199;
  52. * other negative orders also render before the persona.
  53. */
  54. readonly order: number
  55. /**
  56. * Static text or a provider evaluated at each assembly with that assembly's
  57. * {@link AssembleContext}. The text may reference `{{variable}}`s — they are
  58. * interpolated later, by {@link renderPrompt}.
  59. */
  60. readonly text: string | ((context: AssembleContext) => string)
  61. }
  62. /**
  63. * One dynamic model-context contribution. Unlike a {@link PromptSection}, its
  64. * rendered text is materialized as a durable user-role snapshot at the request
  65. * tail, so changing runtime state preserves the stable system/history prefix.
  66. */
  67. export interface PromptContext {
  68. /** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
  69. readonly name: string
  70. /** Contexts are joined in ascending order, independently of system-section order. */
  71. readonly order: number
  72. /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
  73. readonly text: string | ((context: AssembleContext) => string)
  74. }
  75. /** One section of an assembly: {@link PromptSection} with its text resolved. */
  76. export interface AssembledSection {
  77. /** The contributing section's unique name. */
  78. name: string
  79. /** The resolved (but not yet interpolated) section text. */
  80. text: string
  81. }
  82. /** One dynamic context contribution with its text resolved. */
  83. export interface AssembledContext {
  84. /** The contributing context's unique name. */
  85. name: string
  86. /** The resolved (but not yet interpolated) context text. */
  87. text: string
  88. }
  89. /** Tool schemas visible in one assembly and their pre-restriction name set. */
  90. export interface ToolProviderResult {
  91. /** The schemas this provider contributes to THIS assembly. */
  92. readonly schemas: readonly ToolSchema[]
  93. /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
  94. readonly knownNames?: readonly string[]
  95. }
  96. /**
  97. * Merge-extensible assembled model input. Sections and contexts remain
  98. * uninterpolated until their renderers; tools are already in canonical
  99. * model-facing order.
  100. */
  101. export interface PromptAssembly {
  102. sections: AssembledSection[]
  103. contexts: AssembledContext[]
  104. tools: ToolSchema[]
  105. variables: Record<string, string | undefined>
  106. }
  107. /** Valid variable names: how they are written between the braces. */
  108. const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
  109. /** A complete `{{...}}` reference group at the scan position (validated after). */
  110. const GROUP_AT = /^\{\{([^{}]*)\}\}/
  111. /** Reserved {@link Config.toolOrder} marker for unlisted tools. */
  112. export const TOOL_ORDER_REST = '<unlisted-tools>'
  113. /**
  114. * Validate duplicate names and the required {@link TOOL_ORDER_REST} marker.
  115. * Registered names are checked later because plugins have not loaded yet.
  116. */
  117. function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
  118. if (toolOrder === undefined) return undefined
  119. const seen = new Set<string>()
  120. for (const name of toolOrder) {
  121. if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
  122. seen.add(name)
  123. }
  124. if (!seen.has(TOOL_ORDER_REST)) {
  125. throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
  126. }
  127. return toolOrder
  128. }
  129. /**
  130. * Apply configured tool order, inserting unlisted tools lexicographically at
  131. * {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted
  132. * names may be absent.
  133. */
  134. function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
  135. const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
  136. if (reserved !== undefined) {
  137. throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
  138. }
  139. if (toolOrder === undefined) return tools.sort(compareToolNames)
  140. const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
  141. if (unknown.length > 0) {
  142. throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`)
  143. }
  144. const listed = new Set(toolOrder)
  145. const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
  146. return toolOrder.flatMap(name =>
  147. name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
  148. }
  149. /** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
  150. function compareToolNames(a: ToolSchema, b: ToolSchema): number {
  151. return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
  152. }
  153. /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
  154. export interface Config {
  155. /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
  156. includeHarnessIdentity?: boolean
  157. /**
  158. * Deployment-wide order-0 persona template. A scoped section named
  159. * `deployment:persona` shadows it; `{{variable}}` references are strict.
  160. */
  161. persona?: string
  162. /**
  163. * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
  164. * Shape errors fail at load and unknown names fail at assembly; known names
  165. * hidden in one scope may be absent there. Omitted means lexicographic order.
  166. */
  167. toolOrder?: string[]
  168. }
  169. /**
  170. * Interpolate strict `{{variable}}` references, drop empty sections, and join
  171. * the rest with blank lines. Malformed, unknown, or undefined references throw;
  172. * a lone `{{` without any later `}}` is literal prose, and substituted values
  173. * are not scanned again.
  174. * @param assembly - the assembly whose sections and variables to render.
  175. * @returns the rendered prompt, or `''` when all sections are empty.
  176. */
  177. export function renderPrompt(assembly: PromptAssembly): string {
  178. return assembly.sections
  179. .map(section => interpolate(section, assembly.variables, 'section'))
  180. .filter(text => text.length > 0)
  181. .join('\n\n')
  182. }
  183. /**
  184. * Render the complete current dynamic context snapshot. The agent loop appends
  185. * a new durable snapshot only when this text changes or is no longer retained
  186. * after compaction; the explicit supersession clause makes older snapshots in
  187. * history harmless.
  188. * @param assembly - the assembly whose contexts and variables to render.
  189. * @returns the current full snapshot, or `''` when no context is active.
  190. */
  191. export function renderContextSnapshot(assembly: PromptAssembly): string {
  192. const body = assembly.contexts
  193. .map(context => interpolate(context, assembly.variables, 'context'))
  194. .filter(text => text.length > 0)
  195. .join('\n\n')
  196. if (body.length === 0) return ''
  197. return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
  198. }
  199. /** Interpolate one section or context and attribute diagnostics to its owning input. */
  200. function interpolate(
  201. input: AssembledSection | AssembledContext,
  202. variables: Record<string, string | undefined>,
  203. kind: 'section' | 'context',
  204. ): string {
  205. const text = input.text
  206. let result = ''
  207. let last = 0
  208. for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
  209. const group = GROUP_AT.exec(text.slice(open))
  210. if (group === null) {
  211. // A later closing brace makes this malformed; otherwise it is literal prose.
  212. if (text.indexOf('}}', open + 2) >= 0) {
  213. throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
  214. }
  215. result += text.slice(last, open + 2)
  216. last = open + 2
  217. continue
  218. }
  219. // `{{}}` yields an empty name and follows the malformed-reference path.
  220. const name = group[0].slice(2, -2)
  221. if (!VARIABLE_NAME.test(name)) {
  222. throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)
  223. }
  224. // Do not resolve unregistered names through Object.prototype.
  225. if (!Object.hasOwn(variables, name)) {
  226. const known = Object.keys(variables)
  227. throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
  228. }
  229. const value = variables[name]
  230. if (value === undefined) {
  231. throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)
  232. }
  233. result += text.slice(last, open) + value
  234. last = open + group[0].length
  235. }
  236. return result + text.slice(last)
  237. }
  238. /** One tool-schema provider stored in a prompt layer. */
  239. type ToolProvider = (context: AssembleContext) => ToolProviderResult
  240. /** One prompt-variable provider stored in a prompt layer. */
  241. type VariableProvider = (context: AssembleContext) => string | undefined
  242. /** All prompt registrations owned by one global or scoped layer. */
  243. class PromptLayer implements ScopeLayer {
  244. readonly sections: NamedEntries<PromptSection>
  245. readonly contexts: NamedEntries<PromptContext>
  246. readonly toolProviders = new AnonymousEntries<ToolProvider>()
  247. readonly variables: NamedEntries<VariableProvider>
  248. /**
  249. * Create one prompt layer with diagnostics specific to its ownership scope.
  250. * @param scope - the scoped owner, or `undefined` for global registrations.
  251. */
  252. constructor(scope: ScopeKey | undefined) {
  253. this.sections = new NamedEntries(name => new Error(scope === undefined
  254. ? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
  255. : `prompt section "${name}" is already registered in this scope`))
  256. this.contexts = new NamedEntries(name => new Error(scope === undefined
  257. ? `prompt context "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
  258. : `prompt context "${name}" is already registered in this scope`))
  259. this.variables = new NamedEntries(name => new Error(scope === undefined
  260. ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
  261. : `prompt variable "${name}" is already registered in this scope`))
  262. }
  263. /** @returns whether this layer owns no prompt registrations. */
  264. isEmpty(): boolean {
  265. return this.sections.isEmpty()
  266. && this.contexts.isEmpty()
  267. && this.toolProviders.isEmpty()
  268. && this.variables.isEmpty()
  269. }
  270. }
  271. /** Registry service for the prompt inputs assembled before each model step. */
  272. export class SystemPrompt extends Service {
  273. static Config: z<Config> = z.object({
  274. includeHarnessIdentity: z.boolean().default(true),
  275. persona: z.string().default(''),
  276. // Preserve omission because an explicit empty order lacks the rest marker.
  277. toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
  278. })
  279. private readonly layers = new ScopedLayers(
  280. scope => new PromptLayer(scope),
  281. () => { this.ctx.emit('system-prompt/change') },
  282. )
  283. private readonly toolOrder: string[] | undefined
  284. constructor(ctx: Context, config: Config) {
  285. super(ctx, 'systemPrompt')
  286. this.toolOrder = validateToolOrder(config.toolOrder)
  287. // Keep harness-owned openers independent of the selected loop plugin.
  288. if (config.includeHarnessIdentity ?? true) {
  289. this.section({
  290. name: 'harness:identity',
  291. order: -100,
  292. text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
  293. })
  294. }
  295. this.section({
  296. name: 'deployment:persona',
  297. order: 0,
  298. // The fallback narrows the optional input type; the schema already defaults it.
  299. text: config.persona ?? '',
  300. })
  301. }
  302. /**
  303. * Register an ordered prompt section in the calling context's scope. A scoped
  304. * section shadows a global section with the same name; duplicates within one
  305. * layer and non-finite orders throw. Registration and disposal emit
  306. * `system-prompt/change`.
  307. * @param section - the section to register.
  308. * @returns the exact Cordis effect disposer.
  309. */
  310. section(section: PromptSection): () => void {
  311. if (!Number.isFinite(section.order)) {
  312. throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
  313. }
  314. return this.layers.effect(
  315. this.ctx,
  316. layer => layer.sections.insert(section.name, section),
  317. { label: 'systemPrompt.section()' },
  318. )
  319. }
  320. /**
  321. * Register ordered cache-safe dynamic context in the calling context's scope.
  322. * A scoped context shadows a global context with the same name; duplicates
  323. * within one layer and non-finite orders throw. Registration and disposal
  324. * emit `system-prompt/change`.
  325. * @param context - the context contribution to register.
  326. * @returns the exact Cordis effect disposer.
  327. */
  328. context(context: PromptContext): () => void {
  329. if (!Number.isFinite(context.order)) {
  330. throw new TypeError(`prompt context "${context.name}" order must be a finite number`)
  331. }
  332. return this.layers.effect(
  333. this.ctx,
  334. layer => layer.contexts.insert(context.name, context),
  335. { label: 'systemPrompt.context()' },
  336. )
  337. }
  338. /**
  339. * Register a tool-schema provider in the calling context's scope. Global and
  340. * matching scoped providers both contribute; returning the reserved
  341. * {@link TOOL_ORDER_REST} name makes assembly fail.
  342. * @param provider - evaluated for each assembly with its context.
  343. * @returns the exact Cordis effect disposer.
  344. */
  345. tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
  346. return this.layers.effect(
  347. this.ctx,
  348. layer => layer.toolProviders.append(provider),
  349. { label: 'systemPrompt.tools()' },
  350. )
  351. }
  352. /**
  353. * Register a prompt variable in the calling context's scope. Scoped values
  354. * shadow globals; invalid or duplicate names throw. A provider may return
  355. * `undefined`, but rendering a section that references that value then fails.
  356. * @param name - the `[a-z][a-z0-9_]*` reference name.
  357. * @param provider - evaluated for each assembly.
  358. * @returns the exact Cordis effect disposer.
  359. */
  360. variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
  361. if (!VARIABLE_NAME.test(name)) {
  362. throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
  363. }
  364. return this.layers.effect(
  365. this.ctx,
  366. layer => layer.variables.insert(name, provider),
  367. { label: 'systemPrompt.variable()' },
  368. )
  369. }
  370. /**
  371. * Assemble global and scoped providers, detach tool parameters, apply
  372. * canonical ordering, then run the assembly waterfall. Scoped sections and
  373. * variables shadow globals; the returned waterfall value is authoritative.
  374. * @param context - the optional scope and plugin-defined assembly fields.
  375. * @returns the authoritative post-waterfall assembly.
  376. */
  377. // Keep configuration failures on the declared asynchronous error path.
  378. async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
  379. const scope = context.scope
  380. // Scoped variables shadow globals.
  381. const variables: Record<string, string | undefined> = {}
  382. for (const [name, provider] of this.layers.global.variables.entries()) {
  383. variables[name] = provider(context)
  384. }
  385. const scopedVariables = this.layers.peek(scope)?.variables
  386. for (const [name, provider] of scopedVariables?.entries() ?? []) {
  387. variables[name] = provider(context)
  388. }
  389. // Scoped sections shadow globals before the stable order sort.
  390. const sectionByName = this.layers.merge(scope, layer => layer.sections)
  391. const contextByName = this.layers.merge(scope, layer => layer.contexts)
  392. // Validate order against pre-restriction names while collecting visible schemas.
  393. const providers = [
  394. ...this.layers.global.toolProviders.values(),
  395. ...(this.layers.peek(scope)?.toolProviders.values() ?? []),
  396. ]
  397. const collected: ToolSchema[] = []
  398. const knownNames = new Set<string>()
  399. for (const provider of providers) {
  400. const result = provider(context)
  401. const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
  402. name,
  403. description,
  404. parameters: structuredClone(parameters),
  405. }))
  406. const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
  407. collected.push(...schemas)
  408. for (const name of acceptedKnownNames) knownNames.add(name)
  409. }
  410. const assembly: PromptAssembly = {
  411. sections: [...sectionByName.values()]
  412. .sort((a, b) => a.order - b.order)
  413. .map(section => ({
  414. name: section.name,
  415. text: typeof section.text === 'function' ? section.text(context) : section.text,
  416. })),
  417. contexts: [...contextByName.values()]
  418. .sort((a, b) => a.order - b.order)
  419. .map(entry => ({
  420. name: entry.name,
  421. text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
  422. })),
  423. tools: orderTools(collected, this.toolOrder, knownNames),
  424. variables,
  425. }
  426. return this.ctx.waterfall(
  427. scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
  428. () => Promise.resolve(assembly),
  429. )
  430. }
  431. }
  432. export default SystemPrompt