diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 4d541691b29a..e7e6bb7834d2 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -44,6 +44,7 @@ const layer = Layer.effect( const entries = Object.entries(tools) if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) + yield* Effect.forEach(entries, ([name, tool]) => Tool.validate(name, tool), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) yield* state.transform((draft) => { for (const [name, entry] of registrations) draft.set(name, entry) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7ab459..f86263111d48 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" -import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" +import { definition, permission, settle, validate, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" @@ -86,6 +86,7 @@ const registryLayer = Layer.effect( const entries = Object.entries(tools) if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true }) + yield* Effect.forEach(entries, ([name, tool]) => validate(name, tool), { discard: true }) yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e9522d..517490fb458e 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -136,6 +136,11 @@ export const validateName = (name: string) => ? Effect.void : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) +export const validate = (name: string, tool: AnyTool) => + runtimes.has(tool) + ? Effect.void + : Effect.fail(new RegistrationError({ name, message: "Invalid Tool value" })) + export const withPermission = , Output extends SchemaType>( tool: Definition, permission: string, diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 93feeeae3cb8..7d145ec3e8bb 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -83,6 +83,28 @@ describe("ApplicationTools", () => { }), ) + it.effect("rejects invalid opaque tools before mutating application registrations", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const healthy = contextual([]) + yield* applications.register({ healthy }) + + const error = yield* applications + .register({ + ok: contextual([]), + broken: structuredClone(healthy) as Tool.AnyTool, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Tool.RegistrationError) + expect(error.name).toBe("broken") + expect(error.message).toBe("Invalid Tool value") + + expect([...applications.entries().keys()]).toEqual(["healthy"]) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["healthy"]) + }), + ) + it.effect("filters an application tool by its name without adding execution authorization", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 82cd015aaf68..3e0cc3d35045 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -110,6 +110,32 @@ describe("ToolRegistry", () => { }), ) + it.effect("rejects invalid opaque tools before mutating registrations", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ healthy: make() }) + + const error = yield* service + .register({ + ok: make(), + broken: structuredClone(make()) as Tool.AnyTool, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Tool.RegistrationError) + expect(error.name).toBe("broken") + expect(error.message).toBe("Invalid Tool value") + + expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["healthy"]) + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "call-healthy", name: "healthy", input: { text: "ok" } }, + }), + ).toEqual({ type: "text", value: "ok" }) + }), + ) + it.effect("reuses model definitions across provider turns", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service