refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -27,7 +27,7 @@ const echo = Tool.make({
description: "Echo the input",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: (input: { id: number }) => Effect.succeed(input.id),
execute: (input: { id: number }) => Effect.succeed(input.id),
})
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
const toolError = async (code: string) => {

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Definition<never>) =>
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Authorized request was refused")),
execute: () => Effect.fail(toolError("Authorized request was refused")),
}),
)
@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
}),
)
@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail internally",
input: Schema.Struct({}),
output: Schema.String,
run: () => failure,
execute: () => failure,
}),
)
@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ safe: Schema.String }),
run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
}),
)
@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return hostile output",
input: Schema.Struct({}),
output: Schema.Unknown,
run: () =>
execute: () =>
Effect.succeed(
new Proxy(
{},
@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
description: "Refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Refused")),
execute: () => Effect.fail(toolError("Refused")),
}),
},
},
@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
}),
},
},
@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => Effect.succeed(query),
execute: ({ query }) => Effect.succeed(query),
})
const result = await Effect.runPromise(
@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({
@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
properties: { id: { type: "string" }, count: { type: "number" } },
required: ["id"],
},
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return { echoed: input }
@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
},
])
// JSON Schema is render-only: mistyped input passes through unvalidated.
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } })
if (result.ok) expect(result.value).toBeNull()
expect(observed).toStrictEqual([{ id: 42 }])
})
@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
const call = Tool.make({
description: "Observe raw input",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
const find = Tool.make({
description: "Find things",
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => {
},
},
},
run: () => Effect.succeed({ login: "kit", id: 7 }),
execute: () => Effect.succeed({ login: "kit", id: 7 }),
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
})
test("Effect Schema output without an input transform still renders unknown when omitted", async () => {
test("Effect Schema output without an input transform renders void when omitted", async () => {
const ping = Tool.make({
description: "Ping",
input: Schema.Struct({ host: Schema.String }),
run: () => Effect.succeed("pong"),
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toBe("pong")
if (result.ok) expect(result.value).toBeNull()
})
})
@ -554,7 +554,7 @@ describe("CodeMode public contract", () => {
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const tools = { orders: { lookup } }
const source = `return await tools.orders.lookup({ id: "order_42" })`
@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
@ -634,7 +634,7 @@ describe("CodeMode public contract", () => {
description: "Resolve a library ID",
input: Schema.Struct({ libraryName: Schema.String }),
output: Schema.String,
run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
@ -760,18 +760,18 @@ describe("CodeMode public contract", () => {
expect(instructions).not.toContain("search(")
})
test("uses one ranked search returning complete definitions for large catalogs", async () => {
test("uses one ranked search returning complete tools for large catalogs", async () => {
const upload = Tool.make({
description: "Upload one readable local file to the current Discord thread",
input: Schema.Struct({ path: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const generate = Tool.make({
description: "Generate an image and upload it to the current Discord thread",
input: Schema.Struct({ prompt: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
@ -865,7 +865,7 @@ describe("CodeMode public contract", () => {
description: `Numbered tool ${index}`,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -911,7 +911,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -954,13 +954,13 @@ describe("CodeMode public contract", () => {
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
required: ["attachment"],
},
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const other = Tool.make({
description: "Rename the workspace",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
@ -990,7 +990,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -1029,7 +1029,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
// Deliberately declared out of alphabetical order.
const runtime = CodeMode.make({
@ -1071,7 +1071,7 @@ describe("CodeMode public contract", () => {
description: "Cheap",
input: Schema.Struct({ q: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const expensive = Tool.make({
description:
@ -1081,7 +1081,7 @@ describe("CodeMode public contract", () => {
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
@ -1112,7 +1112,7 @@ describe("CodeMode public contract", () => {
},
required: ["id"],
} as const,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: { records: { lookup: documented } },
@ -1130,7 +1130,7 @@ describe("CodeMode public contract", () => {
description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString,
run: ({ value }) =>
execute: ({ value }) =>
Effect.sync(() => {
observed.push(value)
return String(value * 2)
@ -1226,7 +1226,7 @@ describe("CodeMode public contract", () => {
description: "Count invocations",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const result = await Effect.runPromise(
CodeMode.execute({

View file

@ -13,7 +13,7 @@ const echo = (description: string) =>
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
run: ({ value }) => Effect.succeed(value),
execute: ({ value }) => Effect.succeed(value),
})
const tools = {

View file

@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => {
const remove = toolAt(api.tools, "users.remove")
expect(api.skipped).toEqual([])
if (
!Tool.isDefinition(get) ||
!Tool.isDefinition(create) ||
!Tool.isDefinition(search) ||
!Tool.isDefinition(remove)
) {
if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) {
throw new Error("happy-path fixture did not generate every operation")
}
expect(inputTypeScript(get)).toBe(
@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => {
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
const sessionGet = toolAt(result.tools, "v2.session.get")
expect(Tool.isDefinition(sessionGet)).toBe(true)
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
expect(Tool.isDefinition(switchAgent)).toBe(true)
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
expect(Tool.isDefinition(instructionPut)).toBe(true)
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("synthesizes flat operation IDs from methods and paths", () => {
@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => {
"deleteUsersById",
"getOrganizationsByOrganizationidUsersById",
]) {
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
expect(Tool.isTool(toolAt(tools, path))).toBe(true)
}
})
@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ limit: number }")
})
@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => {
})
const search = toolAt(result.tools, "search")
expect(Tool.isDefinition(search)).toBe(true)
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
expect(Tool.isTool(search)).toBe(true)
if (!Tool.isTool(search)) throw new Error("search was not generated")
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
const schema: unknown = search.input
const input = isRecord(schema) ? schema : {}
@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
})
test("projects read-only and write-only properties by schema direction", () => {
for (const version of ["3.0.3", "3.1.0"]) {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
throw new Error(`users.create was not generated for OpenAPI ${version}`)
}
@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const node = isRecord(definitions.Node) ? definitions.Node : {}
@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
}
@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
})
@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
})
@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const body = isRecord(properties.body) ? properties.body : {}
const allOf = Array.isArray(body.allOf) ? body.allOf : []
@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => {
}),
)
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
if (!Tool.isTool(tool)) throw new Error("users.create was not generated")
const result = await Effect.runPromise(
tool
.run({
.execute({
id: "ignored-top-level",
generated: "ignored-generated",
name: "Ada",
@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => {
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
await Effect.runPromise(
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
location
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(result.tools, "items")
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
if (!Tool.isTool(tool)) throw new Error("items was not generated")
await Effect.runPromise(
tool
.run({
.execute({
keys: ["a!", "b*"],
tags: ["x", "y"],
filter: { state: "open", page: 2 },
@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => {
expect(url.searchParams.get("nullable")).toBe("null")
expect(url.searchParams.get("constructor")).toBe("safe")
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"unsupported nested value",
)
await expect(
Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
})
test("preserves ordered exploded and deep-object query parameters", async () => {
@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(
tool
.run({
.execute({
tags: ["first value", "second&value"],
filter: { state: "open now", page: 2 },
location: { directory: "/tmp/a b", workspace: "work&1" },
@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[0]?.url).toBe(
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
)
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Parameter 'tags' contains an unsupported nested value.",
)
await expect(
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
expect(client.requests).toHaveLength(1)
})
@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"getTest",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(inputTypeScript(tool)).toBe("{}")
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => {
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
"test",
)
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated")
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer)))
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
const duplicate = toolAt(
@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"multiple credentials",
)
@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => {
},
})
const alternativeTool = toolAt(alternative.tools, "test")
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
})
@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => {
servers: [{ url: "https://document.example" }],
} satisfies Document
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await expect(
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
expect(resolutions).toEqual([])
expect(client.requests).toEqual([])
@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Invalid JSON body",
)
})
test("rejects oversized and malformed JSON responses", async () => {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const oversized = recordingClient(
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
)
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
"returned malformed JSON",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
})
@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
expect(outputTypeScript(tool)).toBe("string | null")
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
})
test("fails missing required parameters before auth and network", async () => {
@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => {
const update = toolAt(tools, "things.update")
const echo = toolAt(tools, "echo")
expect(Tool.isDefinition(update)).toBe(true)
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
expect(Tool.isTool(update)).toBe(true)
if (!Tool.isTool(update)) throw new Error("things.update was not generated")
expect(inputTypeScript(update)).toBe(
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
)
expect(Tool.isDefinition(echo)).toBe(true)
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
expect(Tool.isTool(echo)).toBe(true)
if (!Tool.isTool(echo)) throw new Error("echo was not generated")
expect(inputTypeScript(echo)).toBe("{ body: string }")
const runtime = CodeMode.make({ tools })
@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => {
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
const tool = toolAt(tools, `body.${name}`)
expect(Tool.isDefinition(tool)).toBe(true)
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
expect(Tool.isTool(tool)).toBe(true)
if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`)
const input = isRecord(tool.input) ? tool.input : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
}
const optional = toolAt(tools, "body.optional")
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
if (!Tool.isTool(optional)) throw new Error("body.optional was not generated")
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
})
})

View file

@ -33,7 +33,7 @@ const echoTool = (trace: Trace) =>
description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.sync(() => {
trace.starts.push(id)
trace.completed += 1
@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
description: "Echo an id once its gate opens",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
description: "Open the gate for an id",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Boolean,
run: ({ id }) => Deferred.succeed(gate(id), undefined),
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
})
const pendingTool = (trace: Trace) =>
@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) =>
description: "Never settle",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@ -98,14 +98,14 @@ const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Lookup refused")),
execute: () => Effect.fail(toolError("Lookup refused")),
})
const interruptedTool = Tool.make({
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
})
const completedTool = (trace: Trace) =>
@ -113,7 +113,7 @@ const completedTool = (trace: Trace) =>
description: "Return the number of completed calls",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(trace.completed),
execute: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) =>
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
run: ({ cleanupMs }) =>
execute: ({ cleanupMs }) =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.andThen(

View file

@ -18,7 +18,8 @@ const listIssues = Tool.make({
},
required: ["owner"],
},
run: () => Effect.succeed("[]"),
output: {},
execute: () => Effect.succeed("[]"),
})
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
@ -31,7 +32,7 @@ const lookupOrder = Tool.make({
output: Schema.Struct({
status: Schema.String.annotate({ description: "Current order status" }),
}),
run: () => Effect.succeed({ status: "open" }),
execute: () => Effect.succeed({ status: "open" }),
})
describe("pretty signature rendering", () => {
@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => {
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }),
execute: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => {
const tool = Tool.make({
description: "Schema tool with awkward field names",
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
run: () => Effect.succeed(null),
execute: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => {
},
} as const,
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
@ -417,7 +418,8 @@ describe("non-identifier tool paths", () => {
},
required: ["query", "libraryName"],
} as const,
run: () => Effect.succeed("/reactjs/react.dev"),
output: {},
execute: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })

View file

@ -329,7 +329,7 @@ describe("RegExp", () => {
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
execute: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const capture = Tool.make({
description: "Capture the exact input the host receives",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"

View file

@ -7,7 +7,7 @@ const echo = (description: string, result: string) =>
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed(result),
execute: () => Effect.succeed(result),
})
const value = async (runtime: CodeMode.Runtime, code: string) => {
@ -88,7 +88,7 @@ describe("callable namespaces", () => {
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
})
test("a namespace without its own definition stays non-callable", async () => {
test("a namespace without its own tool 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")
@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => {
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
})
test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => {
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
const poisoned = CodeMode.make({
tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
})
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
@ -138,7 +138,7 @@ describe("empty segments", () => {
})
describe("canonical path collisions", () => {
test("the last definition supplied for a canonical path wins", async () => {
test("the last tool supplied for a canonical path wins", async () => {
const runtime = CodeMode.make({
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})