|
|
@@ -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))
|