diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 389d3af2760..053bbee6225 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -21,6 +21,7 @@ import { } from "./values.js" const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) +const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0) export type Services = ServicesOf @@ -326,12 +327,15 @@ const describeTool = (path: string, tool: Tool): ToolDescription => ({ signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`, }) +// Discovery bytes are durable instructions, so order only after canonical-path collisions settle. const visibleTools = (tools: Tools) => - flattenTools(toolTrie(tools)).map(({ path, tool }) => ({ - path, - tool, - description: describeTool(path, tool), - })) + flattenTools(toolTrie(tools)) + .sort((left, right) => compareText(left.path, right.path)) + .map(({ path, tool }) => ({ + path, + tool, + description: describeTool(path, tool), + })) export type DiscoveryPlan = { readonly catalog: ReadonlyArray @@ -403,7 +407,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Tool => ({ .filter(({ score }) => terms.length === 0 || score > 0) .sort( (left, right) => - right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), + right.score - left.score || compareText(left.entry.description.path, right.entry.description.path), ) .map(({ entry }) => entry) const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ @@ -462,14 +466,14 @@ export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget group.push(tool) namespaces.set(namespace, group) } - const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) + const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right)) const selections = ordered.map(([namespace, group]) => ({ namespace, picked: new Set(), queue: [...group].sort( (left, right) => - estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path), + estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path), ), })) let used = 0 diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 9d5db0ba583..2d83f4bcb4c 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -629,6 +629,41 @@ describe("CodeMode public contract", () => { } }) + test("renders equivalent catalogs identically regardless of tool insertion order", () => { + const alpha = Tool.make({ + description: "Alpha tool", + input: Schema.Struct({}), + output: Schema.Void, + execute: () => Effect.void, + }) + const zeta = Tool.make({ + description: "Zeta tool", + input: Schema.Struct({}), + output: Schema.Void, + execute: () => Effect.void, + }) + const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } }) + const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } }) + + expect(first.catalog()).toStrictEqual(second.catalog()) + expect(first.instructions()).toBe(second.instructions()) + expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"]) + + for (const catalogBudget of [0, 10, 20, 40]) { + expect( + CodeMode.make({ + tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } }, + discovery: { catalogBudget }, + }).instructions(), + ).toBe( + CodeMode.make({ + tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } }, + discovery: { catalogBudget }, + }).instructions(), + ) + } + }) + test("renders bracket notation for tool names that are not JavaScript identifiers", async () => { const resolveLibrary = Tool.make({ description: "Resolve a library ID", diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 91d23cdee71..359cb7b9c6b 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -106,7 +106,7 @@ describe("blocked member names on tool paths", () => { }) test("tools may use blocked member names because path segments never touch real properties", async () => { - expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"]) + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"]) expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto") expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor") expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor") @@ -155,8 +155,7 @@ describe("canonical path collisions", () => { "issues.close": echo("Close issue", "closed"), }, }) - // Catalog order follows first appearance of each canonical path. - expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"]) + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"]) expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second") expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got") expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed") diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 172041a11d1..c47f35766c4 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -3,13 +3,57 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { CodeMode } from "@opencode-ai/core/codemode" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Effect, Layer } from "effect" +import { Tool } from "@opencode-ai/core/tool/tool" +import { Effect, Layer, Schema } from "effect" import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build"))) describe("CodeModeInstructions", () => { + it.effect("treats equivalent registration orders as an instruction no-op", () => { + const alpha = Tool.make({ + description: "Alpha tool", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "alpha" }), + }) + const zeta = Tool.make({ + description: "Zeta tool", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "zeta" }), + }) + + const codeModeLayer = AppNodeBuilder.build(CodeMode.node) + const layer = Layer.merge( + codeModeLayer, + AppNodeBuilder.build(CodeModeInstructions.node, [[CodeMode.node, codeModeLayer]]), + ) + + return Effect.gen(function* () { + const codeMode = yield* CodeMode.Service + const instructions = yield* CodeModeInstructions.Service + const initialized = yield* Effect.scoped( + Effect.gen(function* () { + yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" })) + return yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + }), + ) + const reordered = yield* Effect.scoped( + Effect.gen(function* () { + yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" })) + return yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, initialized))) + }), + ) + + expect(reordered.changed).toBe(false) + expect(reordered.text).toBe("") + }).pipe(Effect.provide(layer)) + }) + it.effect("renders catalog changes and removal", () => { let catalog: string | undefined = "Initial Code Mode catalog" const layer = AppNodeBuilder.build(CodeModeInstructions.node, [