fix: add tools reorder to system prompt
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user