Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline a23b08af8d fix(codemode): canonicalize dotted tool paths 2026-07-14 21:24:33 -05:00
4 changed files with 198 additions and 30 deletions
+5
View File
@@ -75,6 +75,11 @@ is decoded before `run` is invoked; an Effect Schema `output` is decoded and cop
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
Descriptions and schemas are model-visible contract; keep authorization in `run`.
Dots in tool names are namespace separators: `{ "issues.list": tool }` exposes `tools.issues.list(...)`, exactly like
`{ issues: { list: tool } }`. One canonical path may be both a callable tool and a namespace, and the last definition
supplied for a canonical path wins. Other non-identifier characters stay literal and render with bracket notation,
e.g. `tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
+5 -1
View File
@@ -312,7 +312,11 @@ ultimate source of truth.
These are actionable implementation items. Check them off only when behavior and direct tests land.
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
- [x] Canonicalize dotted tool names into namespace paths so every advertised dotted path is executable, one
canonical path can be both a callable tool and a namespace, and the last definition supplied for a canonical
path wins.
- [ ] Guarantee tool paths containing blocked segments (`constructor`, `prototype`, `__proto__`) are either
executable or never advertised.
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
`null` in render-only or OpenAPI tool calls.
- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly.
+62 -29
View File
@@ -274,15 +274,45 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
return value
}
// Dots in supplied tool names are namespace separators: { "issues.list": tool } and
// { issues: { list: tool } } expose the same canonical path, so every advertised dotted
// path is executable. A node may be both a callable tool and a namespace; the last
// definition supplied for a canonical path wins.
type ToolNode<R> = {
definition?: Definition<R>
readonly children: Map<string, ToolNode<R>>
}
const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const root: ToolNode<R> = { children: new Map() }
const insert = (node: ToolNode<R>, group: Tools<R>): void => {
for (const [name, value] of Object.entries(group)) {
let current = node
for (const segment of name.split(".")) {
const child = current.children.get(segment) ?? { children: new Map() }
current.children.set(segment, child)
current = child
}
if (isDefinition(value)) current.definition = value
else insert(current, value)
}
}
insert(root, tools)
return root
}
// Property access may still spell a canonical path with dotted segments, e.g.
// tools.api["issues.list"]; normalize before walking the trie.
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
const definitions = <R>(
tools: Tools<R>,
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> =>
Object.entries(tools).flatMap(([name, value]) => {
const next = [...path, name]
if (isDefinition(value)) return [{ path: next.join("."), definition: value }]
return definitions(value, next)
})
): Array<{ path: string; definition: Definition<R> }> => [
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(),
]
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
@@ -291,7 +321,7 @@ const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDes
})
const visibleDefinitions = <R>(tools: Tools<R>) =>
definitions(tools).map(({ path, definition }) => ({
definitions(toolTrie(tools)).map(({ path, definition }) => ({
path,
definition,
description: describeDefinition(path, definition),
@@ -555,37 +585,39 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
}
}
const namespaceKeys = <R>(tools: Tools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
let value: Definition<R> | Tools<R> = tools
for (const segment of path) {
if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
const segments = canonicalSegments(path)
let node = root
for (const segment of segments) {
const child = isBlockedMember(segment) ? undefined : node.children.get(segment)
if (child === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${segments.join(".")}'.`, [
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
])
}
value = value[segment] as Definition<R> | Tools<R>
node = child
}
if (isDefinition(value)) return []
return Object.keys(value)
return Array.from(node.children.keys())
}
const resolve = <R>(tools: Tools<R>, path: ReadonlyArray<string>): Definition<R> => {
let value: Definition<R> | Tools<R> = tools
for (const segment of path) {
if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
const segments = canonicalSegments(path)
let node = root
for (const segment of segments) {
const child = isBlockedMember(segment) ? undefined : node.children.get(segment)
if (child === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
"Use search({ query }) to find available described tools.",
])
}
value = value[segment] as Definition<R> | Tools<R>
node = child
}
if (!isDefinition(value)) {
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
if (node.definition === undefined) {
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
}
return value
return node.definition
}
export type ToolRuntime<R = never> = {
@@ -603,6 +635,7 @@ export const make = <R>(
hooks?: ToolCallHooks<R>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = toolTrie(tools)
const searchTool = makeSearchTool(searchIndex)
// End hooks observe settled success or failure; interruption emits neither outcome.
@@ -670,7 +703,7 @@ export const make = <R>(
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(tools, path),
keys: (path) => namespaceKeys(root, path),
search: (args) =>
Effect.suspend(() =>
invokeDefinition(
@@ -681,9 +714,9 @@ export const make = <R>(
),
invoke: (path, args) =>
Effect.gen(function* () {
const name = path.join(".")
const name = canonicalSegments(path).join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const tool = resolve(tools, path)
const tool = resolve(root, path)
return yield* invokeDefinition(name, tool, externalArgs)
}),
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Dots in supplied tool names are namespace separators: { "issues.list": tool } exposes
// the same canonical path as { issues: { list: tool } }, so every advertised dotted path
// is executable. A canonical path can be both a callable tool and a namespace, and the
// last definition supplied for a canonical path wins.
const echo = (description: string, result: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed(result),
})
const value = async (runtime: CodeMode.Runtime, code: string) => {
const result = await Effect.runPromise(runtime.execute(code))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const failure = async (runtime: CodeMode.Runtime, code: string) => {
const result = await Effect.runPromise(runtime.execute(code))
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("dotted tool names", () => {
const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
test("a dotted name becomes nested namespaces in the catalog", () => {
expect(runtime.catalog()).toHaveLength(1)
expect(runtime.catalog()[0]?.path).toBe("api.issues.list")
expect(runtime.catalog()[0]?.signature).toStartWith("tools.api.issues.list(input:")
expect(runtime.instructions()).toContain("tools.api.issues.list(input:")
})
test("the advertised dotted path is executable", async () => {
expect(await value(runtime, `return await tools.api.issues.list({})`)).toBe("listed")
})
test("bracket access with a dotted segment spells the same canonical path", async () => {
expect(await value(runtime, `return await tools.api["issues.list"]({})`)).toBe("listed")
expect(await value(runtime, `return await tools["api.issues"].list({})`)).toBe("listed")
})
test("intermediate segments enumerate like ordinary namespaces", async () => {
expect(await value(runtime, `return [Object.keys(tools.api), Object.keys(tools.api.issues)]`)).toEqual([
["issues"],
["list"],
])
})
test("a top-level dotted name nests from the root", async () => {
const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
})
describe("callable namespaces", () => {
const runtime = CodeMode.make({
tools: { issues: echo("All issues", "all"), "issues.list": echo("List issues", "list") },
})
test("a path can hold a tool and child tools at once", async () => {
expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
expect(runtime.catalog()?.map((tool) => tool.path)).toEqual(["issues", "issues.list"])
})
test("a callable namespace enumerates its children", async () => {
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["list"])
})
test("search returns executable paths for both", async () => {
const result = await value(runtime, `return search({ query: "", namespace: "issues" })`)
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.issues",
"tools.issues.list",
])
const exact = await value(runtime, `return search({ query: "tools.issues.list" })`)
expect((exact as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.issues.list"])
})
test("an unknown child under a callable tool is an UnknownTool error", async () => {
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
})
test("a namespace without its own definition stays non-callable", async () => {
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
const diagnostic = await failure(nested, `return await tools.issues({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Tool 'issues' is not callable")
})
})
describe("canonical path collisions", () => {
test("the last definition supplied for a canonical path wins", async () => {
const runtime = CodeMode.make({
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
expect(runtime.catalog()).toHaveLength(1)
expect(runtime.catalog()[0]?.description).toBe("Second")
})
test("overriding one path keeps sibling tools from both shapes", async () => {
const runtime = CodeMode.make({
tools: {
"issues.list": echo("First list", "first"),
issues: { list: echo("Second list", "second"), get: echo("Get issue", "got") },
"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(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")
})
})