Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 2fb3d5755f fix: settle foreign typed tool failures instead of dropping them
A tool implementation declares `Effect<Result, Tool.Error>` 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.
2026-08-20 00:22:29 -04:00
3 changed files with 41 additions and 2 deletions
+9 -1
View File
@@ -89,7 +89,15 @@ const classifyToolExits = (
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
(reason): Array<Cause.Reason<never>> =>
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)] : []
})
+14 -1
View File
@@ -13,7 +13,20 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, 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 {
+18
View File
@@ -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<ForeignFailure>()("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",