From 2fb3d5755f49c8b722f6bc74a9aed6961dd7cefe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 20 Aug 2026 00:22:29 -0400 Subject: [PATCH] fix: settle foreign typed tool failures instead of dropping them A tool implementation declares `Effect` but a plugin can fail with any typed error at runtime. Such a failure slipped past every `catchTag("Tool.Error")" downstream and past classifyToolExits (which drops Fail reasons on the assumption they are all declines), so the call was never settled: the part stayed "running" forever, the step continued anyway, and the execution reported success. Enforce the declared contract at the untrusted boundary (tool runtime maps foreign typed failures to Tool.Error) and harden classifyToolExits to surface any surviving non-decline typed failure as a defect instead of silently dropping it. --- packages/core/src/session/runner/llm.ts | 10 +++++++++- packages/core/src/tool/runtime.ts | 15 ++++++++++++++- packages/core/test/tool-execute.test.ts | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) 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",