fix(codemode): canonicalize dotted tool paths (#36994)

This commit is contained in:
Aiden Cline 2026-07-15 11:09:09 -05:00 committed by GitHub
commit 22334b94c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 242 additions and 44 deletions

View file

@ -1759,8 +1759,8 @@ export class Interpreter<R> {
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
if (objectValue instanceof ToolReference) {
if (typeof key !== "string" || isBlockedMember(key)) {
throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode)
if (typeof key !== "string") {
throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}

View file

@ -274,15 +274,41 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
return value
}
// Dots in tool names are namespace separators; the last definition 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(".")) {
if (segment === "") throw new TypeError(`Tool name '${name}' contains an empty segment.`)
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
}
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 +317,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 +581,30 @@ 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(".")}'.`, [
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
])
}
value = value[segment] as Definition<R> | Tools<R>
const lookup = <R>(root: ToolNode<R>, segments: ReadonlyArray<string>): ToolNode<R> | undefined =>
segments.reduce<ToolNode<R> | undefined>((node, segment) => node?.children.get(segment), root)
const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${segments.join(".")}'.`)
}
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(".")}'.`, [
"Use search({ query }) to find available described tools.",
])
}
value = value[segment] as Definition<R> | Tools<R>
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
"Use search({ query }) to find available described tools.",
])
}
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 +622,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 +690,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 +701,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)
}),
}

View file

@ -50,8 +50,13 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeTool"
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.