fix: add tools reorder to system prompt

This commit is contained in:
imccyu
2026-07-07 11:03:11 +08:00
parent dc5656fab3
commit 42e7a2f691
18 changed files with 459 additions and 43 deletions
+91 -11
View File
@@ -88,7 +88,8 @@ export interface AssembledSection {
*
* Tool schemas are part of the assembly by design: "what the model is told it
* can do" is one coherent thing managed here, even though adapters transmit
* `tools` as a separate wire field rather than prompt text.
* `tools` as a separate wire field rather than prompt text. They arrive in
* the canonical model-facing order (see {@link Config.toolOrder}).
*
* `variables` carries every registered prompt variable resolved against this
* assembly's context — key present means registered, `undefined` value means
@@ -110,6 +111,53 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
/**
* The rest entry for {@link Config.toolOrder}: the position where registered
* tools not named in the list are inserted (in lexicographic name order).
* Deliberately not a valid model-facing tool name, so it can never collide
* with a real tool.
*/
export const TOOL_ORDER_REST = '...'
/**
* Validate a configured tool-order list at service construction: `'...'`
* ({@link TOOL_ORDER_REST}) exactly once, no duplicate names. Returns the list
* (or undefined when unconfigured); throws otherwise, failing the service at
* load — a bad order config must never reach an assembly.
*/
function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
if (toolOrder === undefined) return undefined
const seen = new Set<string>()
for (const name of toolOrder) {
if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
seen.add(name)
}
if (!seen.has(TOOL_ORDER_REST)) {
throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
}
return toolOrder
}
/**
* Order collected tool schemas by the validated policy: with no configured
* list, plain lexicographic name order; with one, listed names take their
* listed position and every unlisted tool lands at the `'...'` entry in
* lexicographic name order. Never drops a tool, and both sorts are stable, so
* tools sharing a name keep their collection order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
if (toolOrder === undefined) return tools.sort(compareToolNames)
const listed = new Set(toolOrder)
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
return toolOrder.flatMap(name =>
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
}
export interface Config {
/**
* The deployment's persona — the ONE deployment-authored fragment of the
@@ -124,6 +172,22 @@ export interface Config {
* deployment opens with the harness identity alone.
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, names with no registered tool are
* ignored, and tools absent from the list are inserted at the
* {@link TOOL_ORDER_REST} (`'...'`) entry in lexicographic name order. A
* configured list must contain `'...'` exactly once and no duplicate names —
* anything else throws at load; a bad order config must never reach a
* model request. When omitted, tools are ordered lexicographically by name.
* Applied to the tools {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
*/
toolOrder?: string[]
}
/**
@@ -198,14 +262,23 @@ function interpolate(section: AssembledSection, variables: Record<string, string
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
persona: z.string().default(''),
// A schemastery array defaults to [] when omitted, but an omitted
// toolOrder must stay absent ("lexicographic order"), not become an
// explicitly-configured empty list (which is invalid — it lacks the '...'
// entry). Forcing the default to undefined keeps the key out of the
// validated config; the cast is needed because .default() expects the
// array type.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: (() => ToolSchema[])[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// The harness-owned openers. They live HERE (not on the loop plugin) so a
// deployment that swaps in a different loop keeps them: the identity is a
// harness fact stated ahead of everything, and the persona is the
@@ -318,14 +391,19 @@ export class SystemPrompt extends Service {
/**
* Assemble the current prompt for one caller: section texts are resolved
* against `context` and sorted by order, tools collected from all
* providers, and every registered variable resolved against `context` into
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
* request waterfalls may mutate schema objects. Runs through the
* `system-prompt/assemble` waterfall, giving listeners the opportunity to
* mutate or replace the assembly before it reaches the model. Await the
* result before reading the assembly values — waterfall listeners may be
* async. Interpolation happens later, in {@link renderPrompt}.
* against `context` and sorted by order, tools collected from all providers
* and put in the canonical model-facing order ({@link Config.toolOrder}, or
* lexicographic name order when unconfigured — provider registration order
* is a plugin-load artifact and never reaches the assembly), and every
* registered variable resolved against `context` into `assembly.variables`.
* Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly before it reaches the model — like the sections' `order` sort,
* tool canonicalization happens on the initial assembly, and a listener
* owns the determinism of whatever it emits. Await the result before
* reading the assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
* @returns the assembly after the waterfall has run.
@@ -343,8 +421,10 @@ export class SystemPrompt extends Service {
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
tools: orderTools(
this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
this.toolOrder),
variables,
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))