From 9d435362313a3b43fd2a4b2bb0565f8de0fe6966 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 18:50:25 -0700 Subject: [PATCH] feat(agent): project wire tool names per model family --- src/agent/prompt-sizes.ts | 2 +- src/agent/tool-aliases.test.ts | 81 ++++++++++++ src/agent/tool-aliases.ts | 177 ++++++++++++++++++++++----- src/agent/tool-search.ts | 11 +- src/agent/worker-contract.ts | 2 +- src/session/assemble-runtime.test.ts | 40 +++++- src/session/assemble-runtime.ts | 14 ++- src/subagent/run.ts | 12 +- 8 files changed, 299 insertions(+), 40 deletions(-) diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index 70b988463..d3300d8af 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -151,7 +151,7 @@ export function canonicalToolNamesForDirector( `canonicalToolNamesForDirector(${pkg.id}): "${dupe}" mounted twice — the assembly drifted from src/subagent/run.ts`, ); } - return names.map(advertisedToolName); + return names.map((name) => advertisedToolName(name)); } /** Assemble one director prompt exactly as run.ts does. */ diff --git a/src/agent/tool-aliases.test.ts b/src/agent/tool-aliases.test.ts index de0c40cca..9d7821c97 100644 --- a/src/agent/tool-aliases.test.ts +++ b/src/agent/tool-aliases.test.ts @@ -6,6 +6,10 @@ import { canonicalToolName } from "./canonical-tool-name.js"; import { advertisedToolName, authzParityDefinitions, + foldFileToolNames, + projectToolDefinitions, + toolProfileForModel, + translateUpdatePlanArgs, withAuthzParityDefinitions, WIRE_TO_ENGINE, } from "./tool-aliases.js"; @@ -394,3 +398,80 @@ describe("withAuthzParityDefinitions", () => { ]); }); }); + +describe("per-family tool profiles", () => { + const engines = [ + "read_file", + "write_file", + "edit_file", + "delete_file", + "run_shell", + "search_files", + "manage_tasks", + "use_skill", + "web_fetch", + "web_search", + "ask_operator", + "wait_agents", + ]; + + test("every advertised name in every profile dispatches back to its engine", () => { + for (const profile of ["default", "gpt"] as const) { + for (const engine of engines) { + expect(canonicalToolName(advertisedToolName(engine, profile))).toBe( + engine, + ); + } + } + }); + + test("a profile never advertises two names for one engine", () => { + for (const profile of ["default", "gpt"] as const) { + const wires = engines.map((e) => advertisedToolName(e, profile)); + expect(new Set(wires).size).toBe(wires.length); + } + }); + + test("gpt advertises update_plan with the plan schema its dispatcher accepts", () => { + const [projected] = projectToolDefinitions( + [posixDef("manage_tasks")], + "gpt", + ); + if (projected === undefined) throw new Error("expected a projection"); + const props = (projected.inputSchema as { properties: object }).properties; + expect(projected.name).toBe("update_plan"); + expect(Object.keys(props)).toContain("plan"); + expect(Object.keys(props)).not.toContain("tasks"); + expect( + translateUpdatePlanArgs({ + plan: [{ step: "a", status: "in_progress" }], + }), + ).toMatchObject({ action: "create" }); + }); + + test("model families select the profile", () => { + expect( + toolProfileForModel({ providerName: "openai", model: "gpt-5-codex" }), + ).toBe("gpt"); + expect( + toolProfileForModel({ + providerName: "anthropic", + model: "claude-sonnet-5-5", + }), + ).toBe("default"); + }); + + test("gpt folds write/edit/delete into apply_patch only", () => { + expect(foldFileToolNames(["read", "write", "edit", "bash"], "gpt")).toEqual( + ["read", "apply_patch", "bash"], + ); + expect(foldFileToolNames(["read", "bash"], "gpt")).toEqual([ + "read", + "bash", + ]); + expect(foldFileToolNames(["write", "edit"], "default")).toEqual([ + "write", + "edit", + ]); + }); +}); diff --git a/src/agent/tool-aliases.ts b/src/agent/tool-aliases.ts index 3c731441c..c0b76cc0f 100644 --- a/src/agent/tool-aliases.ts +++ b/src/agent/tool-aliases.ts @@ -1,37 +1,114 @@ /** - * One advertised posix set (CL-8400). Registry engines stay posix-named; - * advertise is a projection onto wire names. Incoming aliases resolve onto - * the same engine id for dispatch and grants. + * Advertised tool names are a projection of registry engine ids that depends + * on the model family: each family sees exactly one name per tool, the one it + * was trained on. Incoming calls accept every name any profile advertises, so + * dispatch, grants, and history replay never depend on the active profile. * - * Wire: read write edit delete bash grep glob - * Engine: read_file write_file edit_file delete_file run_shell grep search_files - * Hidden dispatch: shell → run_shell (Codex argv/workdir/timeout_ms coerce), - * update_plan → manage_tasks. apply_patch is neither advertised nor dispatched. + * default (industry-common): read write edit delete bash glob todowrite skill + * webfetch websearch question + * gpt (Codex): default, except shell, update_plan, wait */ import { type } from "arktype"; import type { ToolCall, ToolDefinition } from "@intx/types/runtime"; import { canonicalToolName } from "./canonical-tool-name.js"; +import { + detectModelFamily, + type ModelFamily, +} from "../subagent/provider-family.js"; -/** Advertised posix names → registry engine ids. 1:1, never dual-publish. */ -export const WIRE_TO_ENGINE = { - read: "read_file", - write: "write_file", - edit: "edit_file", - delete: "delete_file", - bash: "run_shell", - glob: "search_files", +export type ToolProfile = "default" | "gpt"; + +/** Registry engine ids → default wire names. */ +const DEFAULT_ENGINE_TO_WIRE = { + read_file: "read", + write_file: "write", + edit_file: "edit", + delete_file: "delete", + run_shell: "bash", + search_files: "glob", + manage_tasks: "todowrite", + use_skill: "skill", + web_fetch: "webfetch", + web_search: "websearch", + ask_operator: "question", } as const; -/** Hidden incoming names that dispatch onto a mounted engine (not advertised). */ -export const HIDDEN_TO_ENGINE = { - shell: "run_shell", - update_plan: "manage_tasks", +const GPT_OVERRIDES = { + run_shell: "shell", + manage_tasks: "update_plan", + wait_agents: "wait", } as const; -const ENGINE_TO_WIRE: Record = Object.fromEntries( - Object.entries(WIRE_TO_ENGINE).map(([wire, engine]) => [engine, wire]), -); +const ENGINE_TO_WIRE_BY_PROFILE: Record< + ToolProfile, + Readonly> +> = { + default: DEFAULT_ENGINE_TO_WIRE, + gpt: { ...DEFAULT_ENGINE_TO_WIRE, ...GPT_OVERRIDES }, +}; + +/** Tools the gpt profile folds into the single apply_patch envelope tool. */ +const PATCH_FOLDED_ENGINES: ReadonlySet = new Set([ + "write_file", + "edit_file", + "delete_file", +]); + +/** + * gpt models are trained on apply_patch, not write/edit/delete. Replace the + * first folded name with apply_patch and drop the rest; other profiles and + * lists with no file-mutation tool pass through unchanged. + */ +export function foldFileToolNames( + names: readonly string[], + profile: ToolProfile, +): readonly string[] { + if (profile !== "gpt") return names; + const folded = (name: string): boolean => + PATCH_FOLDED_ENGINES.has(engineToolName(name)); + if (!names.some(folded)) return names; + const first = names.findIndex(folded); + return names.flatMap((name, i) => + i === first ? ["apply_patch"] : folded(name) ? [] : [name], + ); +} + +export function foldFileToolDefinitions( + defs: readonly ToolDefinition[], + profile: ToolProfile, +): ToolDefinition[] { + if (profile !== "gpt" || !defs.some((d) => d.name === "apply_patch")) { + return [...defs]; + } + return defs.filter((d) => !PATCH_FOLDED_ENGINES.has(engineToolName(d.name))); +} + +export function toolProfileForFamily(family: ModelFamily): ToolProfile { + return family === "gpt" ? "gpt" : "default"; +} + +export function toolProfileForModel(input: { + providerName: string; + model?: string; +}): ToolProfile { + return toolProfileForFamily(detectModelFamily(input)); +} + +/** Default wire names → registry engine ids. 1:1, never dual-publish. */ +export const WIRE_TO_ENGINE: Readonly> = + Object.fromEntries( + Object.entries(DEFAULT_ENGINE_TO_WIRE).map(([engine, wire]) => [ + wire, + engine, + ]), + ); + +/** Names only a non-default profile advertises; still accepted on dispatch. */ +export const HIDDEN_TO_ENGINE: Readonly> = + Object.fromEntries( + Object.entries(GPT_OVERRIDES).map(([engine, wire]) => [wire, engine]), + ); const ALIAS_TO_ENGINE: Record = { ...WIRE_TO_ENGINE, @@ -47,9 +124,16 @@ export function engineToolName(requested: string): string { ); } -/** Project a registry engine id onto the advertised wire name. */ -export function advertisedToolName(engine: string): string { - return ENGINE_TO_WIRE[engine] ?? engine; +/** Project a registry engine id onto the profile's advertised wire name. */ +export function advertisedToolName( + engine: string, + profile: ToolProfile = "default", +): string { + const table = + typeof profile === "string" && profile in ENGINE_TO_WIRE_BY_PROFILE + ? ENGINE_TO_WIRE_BY_PROFILE[profile] + : ENGINE_TO_WIRE_BY_PROFILE.default; + return table[engine] ?? engine; } /** @@ -67,15 +151,52 @@ export function nameMatchesAdvertisedListing( return wire !== name && isListed(wire); } -export function projectToolDefinition(def: ToolDefinition): ToolDefinition { - const wire = advertisedToolName(def.name); +// update_plan dispatches through translateUpdatePlanArgs, which only accepts +// the Codex { plan: [{ step, status }] } shape. Advertising manage_tasks's +// schema under that name sends the model into a rejected-call loop. +const UPDATE_PLAN_DEFINITION = { + description: + "Your work checklist for multi-step jobs. Send the full plan each call; keep at most one step in_progress. Skip for one-step work.", + inputSchema: { + type: "object", + properties: { + explanation: { type: "string" }, + plan: { + type: "array", + items: { + type: "object", + properties: { + step: { type: "string" }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed"], + }, + }, + required: ["step", "status"], + }, + }, + }, + required: ["plan"], + }, +} as const; + +export function projectToolDefinition( + def: ToolDefinition, + profile: ToolProfile = "default", +): ToolDefinition { + const engine = canonicalToolName(def.name); + const wire = advertisedToolName(engine, profile); + if (wire === "update_plan" && engine === "manage_tasks") { + return { ...def, name: wire, ...UPDATE_PLAN_DEFINITION }; + } return wire === def.name ? def : { ...def, name: wire }; } export function projectToolDefinitions( defs: readonly ToolDefinition[], + profile: ToolProfile = "default", ): ToolDefinition[] { - return defs.map(projectToolDefinition); + return defs.map((def) => projectToolDefinition(def, profile)); } /** diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 960530efe..322f6cb11 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -11,7 +11,11 @@ import { } from "./lexical-rank.js"; import type { SessionMode } from "../config/session-mode.js"; import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; -import { advertisedToolName, projectToolDefinition } from "./tool-aliases.js"; +import { + advertisedToolName, + projectToolDefinition, + type ToolProfile, +} from "./tool-aliases.js"; import { canonicalToolName } from "./canonical-tool-name.js"; // Tools whose full schema is always advertised to the model. Everything else is @@ -180,13 +184,14 @@ export function advertisedTools( all: readonly ToolDefinition[], activated: readonly string[] = [], builtInPrefix: readonly string[] = ADVERTISED_TOOL_NAMES, + profile: ToolProfile = "default", ): ToolDefinition[] { const byName = new Map(); for (const def of all) { byName.set(def.name, def); const engine = canonicalToolName(def.name); if (!byName.has(engine)) byName.set(engine, def); - const wire = advertisedToolName(engine); + const wire = advertisedToolName(engine, profile); if (!byName.has(wire)) byName.set(wire, def); } const seen = new Set(); @@ -197,7 +202,7 @@ export function advertisedTools( return orderedNames.flatMap((name) => { const def = byName.get(name) ?? byName.get(canonicalToolName(name)); if (def === undefined) return []; - const projected = projectToolDefinition(def); + const projected = projectToolDefinition(def, profile); if (seen.has(projected.name)) return []; seen.add(projected.name); return [projected]; diff --git a/src/agent/worker-contract.ts b/src/agent/worker-contract.ts index 947f64a21..af64310db 100644 --- a/src/agent/worker-contract.ts +++ b/src/agent/worker-contract.ts @@ -39,5 +39,5 @@ export function buildWorkerContract(opts: WorkerContractOptions = {}): string { * primary chat prompt. */ export function buildWorkerToolNames(toolNames: readonly string[]): string { - return `Tools (names only): ${toolNames.map(advertisedToolName).join(", ")}`; + return `Tools (names only): ${toolNames.map((name) => advertisedToolName(name)).join(", ")}`; } diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index c21426e5f..415217be5 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -48,10 +48,46 @@ describe("createAdvertisedToolset", () => { def("write_file"), def("mystery_tool"), ]).map((d) => d.name); - expect(names).toContain("write"); expect(names).not.toContain("mystery_tool"); }); + test("claude sees write/edit/delete; gpt sees one apply_patch instead", () => { + const registry = [ + def("write_file"), + def("edit_file"), + def("delete_file"), + def("apply_patch"), + def("run_shell"), + def("manage_tasks"), + ]; + const claude = createAdvertisedToolset( + wiring({ + getProvider: () => ({ + providerName: "anthropic", + model: "claude-sonnet-5-5", + }), + }), + ).computeAdvertised(registry); + expect(claude.map((d) => d.name)).toEqual([ + "write", + "edit", + "delete", + "bash", + "todowrite", + ]); + + const gpt = createAdvertisedToolset( + wiring({ + getProvider: () => ({ providerName: "openai", model: "gpt-5-codex" }), + }), + ).computeAdvertised(registry); + expect(gpt.map((d) => d.name)).toEqual([ + "apply_patch", + "shell", + "update_plan", + ]); + }); + // Activation opens the call gate but does not reshape the wire set until // flushPromotions commits it (promoters flush on execute; this unit stays // the split). @@ -311,7 +347,7 @@ describe("createAdvertisedToolset", () => { def("use_skill"), ]).map((d) => d.name); expect(names).toContain("skill_search"); - expect(names).toContain("use_skill"); + expect(names).toContain("skill"); expect(isAdvertised("skill_search")).toBe(true); } }); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 8e7960c5c..5e2ef5d15 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -51,7 +51,9 @@ import { type ToolAvailability, } from "../agent/tool-search.js"; import { + foldFileToolNames, nameMatchesAdvertisedListing, + toolProfileForModel, withAuthzParityDefinitions, } from "../agent/tool-aliases.js"; import { canonicalToolName } from "../agent/canonical-tool-name.js"; @@ -440,15 +442,17 @@ export function createAdvertisedToolset(args: { ): ToolDefinition[] => { const provider = args.getProvider(); const denied = deniedFor(provider); - const gatedPrefix = + const profile = toolProfileForModel(provider); + const rawGated = denied.length === 0 ? prefix : prefix.filter((name) => !denied.includes(name)); + const gatedPrefix = foldFileToolNames(rawGated, profile); // The wire carries the fixed prefix plus wire-committed activations only: // fresh activations open the call gate (isAdvertised) at once but stay off // this array until flushPromotions commits them. return normalizeToolDefinitionsForProvider( - advertisedTools(all, wireActivated, gatedPrefix), + advertisedTools(all, wireActivated, gatedPrefix, profile), { ...provider, }, @@ -459,9 +463,13 @@ export function createAdvertisedToolset(args: { if (denied.includes(name) || denied.includes(canonicalToolName(name))) { return false; } + const effective = foldFileToolNames( + prefix, + toolProfileForModel(args.getProvider()), + ); return nameMatchesAdvertisedListing( name, - (n) => prefix.includes(n) || activated.has(n), + (n) => effective.includes(n) || activated.has(n), ); }; const flushPromotions = (): boolean => { diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 272a7404c..c5e2b6ea4 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -73,7 +73,9 @@ import { } from "./capability-preflight.js"; import { advertisedToolName, + foldFileToolDefinitions, projectToolDefinitions, + toolProfileForModel, withAuthzParityDefinitions, } from "../agent/tool-aliases.js"; @@ -1074,7 +1076,10 @@ async function runSubAgentInner( : []), ...(attachedSection !== undefined ? [attachedSection] : []), ]; - const toolNames = tools.map((t) => advertisedToolName(t.definition.name)); + const toolProfile = toolProfileForModel(params.provider); + const toolNames = tools.map((t) => + advertisedToolName(t.definition.name, toolProfile), + ); const systemPrompt = buildSubAgentSystemPrompt( extensions.length > 0 ? extensions : undefined, environment, @@ -1125,7 +1130,10 @@ async function runSubAgentInner( const director = new SubAgentDirector( agentCtx.systemPrompt, normalizeToolDefinitionsForProvider( - projectToolDefinitions([...agentCtx.toolDefinitions]), + projectToolDefinitions( + foldFileToolDefinitions(agentCtx.toolDefinitions, toolProfile), + toolProfile, + ), { providerName: params.provider.providerName, model: params.provider.model,