fix(codemode): report original tool errors (#45820)

This commit is contained in:
Aiden Cline 2026-08-27 22:54:36 -05:00 committed by GitHub
parent 38bffc9db1
commit 56f2559798
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 71 additions and 59 deletions

View file

@ -19,7 +19,7 @@
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Report host failure messages and underlying causes rather than replacing them with generic diagnostics. Preserve interruption behavior.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.

View file

@ -139,8 +139,8 @@ Diagnostic kinds:
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
Host failures and defects report their messages and underlying causes. Invalid outputs include the validation or
copying error. Interruption propagates without becoming an error diagnostic.
## Discovery

View file

@ -365,7 +365,7 @@ ultimate source of truth.
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.

View file

@ -1,11 +1,11 @@
import { Schema } from "effect"
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
/** Tool failure reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedError<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
/** Creates a tool failure with an optional underlying cause. */
export const toolError = (message: string, cause?: unknown): ToolError =>
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })

View file

@ -1,5 +1,5 @@
import { Cause, Effect, Exit, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
import { toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
@ -117,15 +117,6 @@ export class ToolRuntimeError extends Error {
}
}
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
)
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
@ -507,19 +498,12 @@ export const make = <R>(
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const decodeOutput = (value: unknown, name: string) =>
Effect.try({
try: () => copyIn(value, `Result from tool '${name}'`),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
@ -548,12 +532,22 @@ export const make = <R>(
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
return Effect.fail(
toolError(
Cause.prettyErrors(cause)
.map((error) => (error.cause ? Formatter.format(error) : error.message || error.name))
.join("\n"),
),
)
}),
)
return yield* Effect.try({
try: () => copyIn(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
return yield* decodeOutput(result, name)
}),
call,
)

View file

@ -5,15 +5,15 @@ import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedError<UnsafeHostError>()("UnsafeHostError", {
reason: Schema.String,
class HostError extends Schema.TaggedError<HostError>()("HostError", {
message: Schema.String,
}) {}
describe("CodeMode host failure boundary", () => {
test("preserves explicit safe tool failures", async () => {
test("preserves explicit tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail safely",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Authorized request was refused")),
@ -26,10 +26,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("does not rewrite explicit safe tool failures", async () => {
test("does not rewrite explicit tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail safely",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
@ -42,10 +42,15 @@ describe("CodeMode host failure boundary", () => {
})
})
test("sanitizes unknown host failures and defects", async () => {
test("reports failures, defects, rejected Promises, and nested causes", async () => {
for (const failure of [
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
Effect.fail(new HostError({ message: "Connection refused" })),
Effect.die(new Error("Connection refused")),
Effect.promise(async () => {
throw new Error("Connection refused")
}),
Effect.fail(toolError("Request failed", new Error("Connection refused"))),
Effect.failCause(Cause.combine(Cause.fail("Request failed"), Cause.die("Connection refused"))),
]) {
const result = await run(
Tool.make({
@ -58,31 +63,28 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "ToolFailure",
message: "Tool execution failed",
message: expect.stringContaining("Connection refused"),
})
expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
}
})
test("sanitizes invalid host output", async () => {
const secret = "invalid-output-secret"
test("reports invalid host output", async () => {
const result = await run(
Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ safe: Schema.String }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.succeed({ value: 1 } as unknown as { readonly value: string }),
}),
)
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call'.",
message: "Invalid output from tool 'host.call': SchemaError(Expected string\n at [\"value\"])",
})
expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
})
test("sanitizes host output that throws while being copied", async () => {
test("reports host output copying errors", async () => {
const result = await run(
Tool.make({
description: "Return hostile output",
@ -94,7 +96,7 @@ describe("CodeMode host failure boundary", () => {
{},
{
ownKeys: () => {
throw new Error("host-output-secret")
throw new Error("Cannot enumerate output")
},
},
),
@ -104,9 +106,8 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call'.",
message: "Invalid output from tool 'host.call': Error: Cannot enumerate output",
})
expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
})
test("caught tool failures are Error values in-program", async () => {
@ -229,7 +230,7 @@ describe("CodeMode tool-call observation", () => {
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
])
})

View file

@ -65,9 +65,7 @@ export const create = (
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((failure) => toolError(failure.message, failure)),
)
const executed = yield* executeTool(name, tool, input, context)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]

View file

@ -915,7 +915,7 @@ describe("fromPromise", () => {
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
it.effect("returns content-only plugin results and rejected Promises through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
@ -927,8 +927,11 @@ describe("fromPromise", () => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
input: Schema.Struct({ fail: Schema.optionalKey(Schema.Boolean) }),
execute: async ({ fail }) => {
if (fail) await ctx.session.create({ agent: undefined })
return { content: [{ type: "text", text: "hello" }] }
},
options: { codemode: true },
})
})
@ -953,6 +956,22 @@ describe("fromPromise", () => {
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_failed_tool",
name: "execute",
input: { code: "return await tools.demo_status({ fail: true })" },
},
}),
).toMatchObject({
content: [{ type: "text", text: 'Expected string | null\n at ["agent"]' }],
metadata: { error: true },
})
}),
)
})