feat(system-prompt): cache dynamic policy context

This commit is contained in:
NI0317
2026-07-30 22:09:15 +08:00
parent 936a487bb0
commit 8b4cbe4293
45 changed files with 722 additions and 374 deletions

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>
}
@@ -175,6 +199,23 @@ export function renderPrompt(assembly: PromptAssembly): string {
.join('\n\n')
}
/**
* 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))
.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's `{{variable}}` references (see {@link renderPrompt}). */
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
const text = section.text
@@ -220,6 +261,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 +273,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 +284,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 +343,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 +417,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 +443,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')
}