feat(system-prompt): cache dynamic policy context
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user