diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 48f33911e68b..0e8be05f64dd 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -89,7 +89,15 @@ const classifyToolExits = ( .flatMap((cause) => { if (Cause.hasInterrupts(cause)) return [] const reasons = cause.reasons.flatMap( - (reason): Array> => (Cause.isFailReason(reason) ? [] : [reason]), + (reason): Array> => + Cause.isFailReason(reason) + ? isDecline(reason.error) + ? [] + : // A typed failure here broke the ExecuteError contract (the per-fiber + // `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect + // keeps it from being dropped, which would leave its call unsettled forever. + [Cause.makeDieReason(reason.error)] + : [reason], ) return reasons.length > 0 ? [Cause.fromReasons(reasons)] : [] }) diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index f75dc543a421..279ece4135f4 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -13,7 +13,20 @@ export const definition = (tool: Tool.Info): ToolDefinition => ({ export const execute = (tool: Tool.Info, input: unknown, context: Tool.Context) => Effect.gen(function* () { const decoded = yield* decodeInput(tool.input, input) - const result = yield* tool.execute(decoded, context) + // Tool implementations declare `Tool.Error` but plugins can fail with anything at + // runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")` + // downstream and leave its call permanently unsettled, so the declared contract is + // enforced here at the untrusted boundary. Declines tunnel through as defects and + // interrupts are not errors; neither is touched. + const result = yield* tool.execute(decoded, context).pipe( + Effect.mapError((error: unknown) => + error instanceof Tool.Error + ? error + : new Tool.Error({ + message: error instanceof globalThis.Error ? error.message : String(error), + }), + ), + ) if (tool.output === undefined) { if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema") return { diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index f5e07ff7044d..ba13b6a5bf8a 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -94,6 +94,24 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat ) }) +test("foreign typed failures settle as Tool.Error at the untrusted boundary", async () => { + class ForeignFailure extends Schema.TaggedError()("Plugin.ForeignFailure", { + message: Schema.String, + }) {} + const lying: Info = { + name: "lying", + description: "Fails with a non-Tool.Error typed failure", + input: Schema.Struct({}), + execute: () => new ForeignFailure({ message: "transport died" }) as never, + } + + const exit = await Effect.runPromiseExit(execute(lying, {}, context)) + expect(exit._tag).toBe("Failure") + const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined + expect(error).toBeInstanceOf(Tool.Error) + expect((error as Tool.Error).message).toBe("transport died") +}) + test("execute supports callable namespace tools", async () => { const callable: Info = { name: "admin",