diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 2d38ea7cecf..9e325b9985a 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -2,7 +2,7 @@ export * as Tool from "./tool.js" export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool" export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool" -import type { ToolCall, ToolDefinition } from "@opencode-ai/ai" +import { ToolDefinition, type ToolCall } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/schema/tool" import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -100,7 +100,7 @@ const layer = Layer.effect( const execution = yield* execute(tool, beforeEvent.input, context).pipe( Effect.map((value) => ({ value })), Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })), - ) + ) const base = { tool: name, sessionID: context.sessionID, @@ -165,6 +165,19 @@ const layer = Layer.effect( }), ) if (entries.length === 0) return + yield* Effect.forEach( + entries, + (entry) => + Effect.try({ + try: () => ToolDefinition.make(definition(entry.tool)), + catch: (error) => + new RegistrationError({ + name: entry.key, + message: `Invalid tool definition ${entry.key}: ${error instanceof Error ? error.message : String(error)}`, + }), + }), + { discard: true }, + ) yield* Effect.uninterruptible( lock.withPermit( Effect.gen(function* () { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index b3fb1029a0b..a1be5eddab1 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -110,6 +110,28 @@ describe("Tool", () => { }), ) + it.effect("rejects invalid tool definitions before installing any tools", () => + Effect.gen(function* () { + const service = yield* Tool.Service + const error = yield* service + .transform((draft) => { + draft.add({ ...make(), name: "healthy", options: { codemode: false } }) + draft.add({ + name: "phone_type", + input: Schema.Struct({}), + execute: () => Effect.succeed({ content: "ok" }), + options: { codemode: false }, + } as unknown as Info) + }) + .pipe(Effect.flip) + + expect(error).toBeInstanceOf(Tool.RegistrationError) + expect(error.name).toBe("phone_type") + expect(error.message).toContain('Expected string, got undefined\n at ["description"]') + expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"]) + }), + ) + it.effect("canonicalizes effective definitions and keeps Code Mode last", () => Effect.gen(function* () { const service = yield* Tool.Service