index.ts 20 KB

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