refactor(agent-loop): project runtime context before steps

This commit is contained in:
_Kerman
2026-08-01 20:39:10 +08:00
parent 1a09174987
commit d38c8bfaf3
11 changed files with 255 additions and 384 deletions

View File

@@ -1,5 +1,5 @@
/**
* Registry for ordered prompt sections, tool schemas, and prompt variables.
* Registry for ordered system sections, dynamic 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,16 @@ export interface PromptSection {
readonly text: string | ((context: AssembleContext) => string)
}
/** Dynamic model context materialized as a durable user-role snapshot. */
export interface PromptContext {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
readonly name: string
/** Contexts are joined in ascending 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 +83,14 @@ export interface AssembledSection {
text: string
}
/** One resolved dynamic context contribution. */
export interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved text before variable interpolation. */
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 +100,12 @@ 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 rendered; tools are already in canonical order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
@@ -170,14 +189,32 @@ export interface Config {
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables))
.map(section => interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
const text = section.text
/**
* Render the complete dynamic context snapshot.
* @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, 'context'))
.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 or context and attribute diagnostics to its owner. */
function interpolate(
input: AssembledSection | AssembledContext,
variables: Record<string, string | undefined>,
kind: 'section' | 'context',
): string {
const text = input.text
let result = ''
let last = 0
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
@@ -185,7 +222,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
if (group === null) {
// A later closing brace makes this malformed; otherwise it is literal prose.
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)`)
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
}
result += text.slice(last, open + 2)
last = open + 2
@@ -194,16 +231,16 @@ function interpolate(section: AssembledSection, variables: Record<string, string
// `{{}}` yields an empty name and follows the malformed-reference path.
const name = group[0].slice(2, -2)
if (!VARIABLE_NAME.test(name)) {
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)
}
// Do not resolve unregistered names through Object.prototype.
if (!Object.hasOwn(variables, name)) {
const known = Object.keys(variables)
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.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}")`)
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)
}
result += text.slice(last, open) + value
last = open + group[0].length
@@ -220,6 +257,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 +269,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 +280,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 +339,23 @@ export class SystemPrompt extends Service {
)
}
/**
* Register ordered dynamic context in the calling context's scope. Scoped
* entries shadow global entries with the same name.
* @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 +411,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 +437,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,
}