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