Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/codemode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ Diagnostic kinds:
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.

`tunnelDefect` opts specific host defects out of that sanitization. A defect it accepts — a user's permission decline,
for example — is not a tool failure but a decision to stop, so it tears the execution down the way an interruption
does (program `try`/`catch` cannot observe it) and is re-raised to the caller as a defect rather than becoming a
`ToolFailure` the model can ignore. Everything else is still sanitized.

## Discovery

`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for
Expand Down
3 changes: 2 additions & 1 deletion packages/codemode/interpreter-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ ultimate source of truth.
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted
without a warning.
- [x] `try`/`catch` can handle awaited tool and promise failures.
- [x] `try`/`catch` can handle awaited tool and promise failures, except a defect the host's `tunnelDefect`
classifier accepts: that tears execution down like an interruption, so no program `catch` observes it.
- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
Expand Down
8 changes: 8 additions & 0 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
tools?: Provided & Tools<Services<Provided>>
/** Per-execution overrides for the default resource limits. */
limits?: ExecutionLimits
/**
* Classifies host defects the host deliberately tunnels through this runtime — a user's
* permission decline, for example. A matching defect tears the execution down the way an
* interruption does, so program `try`/`catch` cannot observe it, and is re-raised to the
* caller unchanged instead of being sanitized into a `ToolFailure` diagnostic. Absent means
* every defect is sanitized.
*/
tunnelDefect?: (defect: unknown) => boolean
/** Observes decoded tool input immediately before tool execution. */
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
Expand Down
35 changes: 27 additions & 8 deletions packages/codemode/src/interpreter/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>

// Allocate execution state inside suspension so reused Effects never share it.
return Effect.suspend(() => {
// First tunneled defect wins: it is the decision that aborted the program, and whatever
// teardown produced afterwards is a consequence of it.
let tunneled: { readonly defect: unknown } | undefined
const matches = options.tunnelDefect
const tunnel: ToolRuntime.DefectTunnel | undefined =
matches === undefined
? undefined
: {
matches,
record: (defect) => {
tunneled ??= { defect }
},
}
const tools = ToolRuntime.make(
(options.tools ?? {}) as Tools<Services<Provided>>,
limits.maxToolCalls,
Expand All @@ -32,6 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
onToolCallStart: options.onToolCallStart,
onToolCallEnd: options.onToolCallEnd,
},
tunnel,
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
Expand Down Expand Up @@ -102,15 +116,20 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>

return operation.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.succeed({
ok: false,
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies Result),
tunneled !== undefined
? Effect.die(tunneled.defect)
: Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.succeed({
ok: false,
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies Result),
),
// An un-awaited call can be declined without the program itself failing. The decline still
// aborts the execution, so it outranks an otherwise successful result.
Effect.flatMap((result) => (tunneled === undefined ? Effect.succeed(result) : Effect.die(tunneled.defect))),
Effect.map((result) =>
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
),
Expand Down
36 changes: 34 additions & 2 deletions packages/codemode/src/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,38 @@ export class ToolRuntimeError extends Error {
}
}

const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
/**
* Carries a host defect that must abort the whole execution instead of being sanitized into a
* tool diagnostic. `matches` classifies the defect; `record` hands it to the execution boundary,
* which re-raises it once teardown finishes.
*/
export type DefectTunnel = {
readonly matches: (defect: unknown) => boolean
readonly record: (defect: unknown) => void
}

const recordTunneled = (cause: Cause.Cause<unknown>, tunnel: DefectTunnel | undefined): boolean => {
if (tunnel === undefined) return false
for (const reason of cause.reasons) {
if (!Cause.isDieReason(reason) || !tunnel.matches(reason.defect)) continue
tunnel.record(reason.defect)
return true
}
return false
}

const runHost = <A, E, R>(
effect: Effect.Effect<A, E, R>,
tunnel: DefectTunnel | undefined,
): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
// A tunneled defect is a user's "no", not a tool failure: sanitizing it here would hand the
// model a generic error and let the step continue. Riding the interrupt path makes it
// uncatchable in-program, exactly like the direct call it stands in for; the recorded defect
// is re-raised to the host at the execution boundary.
if (recordTunneled(cause, tunnel)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
Expand Down Expand Up @@ -492,6 +520,7 @@ export const make = <R>(
maxToolCalls: number | undefined,
searchIndex: ReadonlyArray<SearchEntry>,
hooks?: ToolCallHooks<R>,
tunnel?: DefectTunnel,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = toolTrie(tools)
Expand Down Expand Up @@ -548,7 +577,10 @@ 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 raw = yield* runHost(
Effect.suspend(() => tool.execute(input)),
tunnel,
)
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
Expand Down
77 changes: 76 additions & 1 deletion packages/codemode/test/codemode.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"

const run = (tool: Tool.Tool<never>) =>
Expand Down Expand Up @@ -159,6 +159,81 @@ describe("CodeMode host failure boundary", () => {
})
})

class DeclinedError extends Schema.TaggedErrorClass<DeclinedError>()("DeclinedError", {}) {}

const declineRuntime = (code: string, execute: () => Effect.Effect<string>) =>
Effect.runPromiseExit(
CodeMode.make({
tools: {
host: {
call: Tool.make({
description: "Ask for permission",
input: Schema.Struct({}),
output: Schema.String,
execute,
}),
},
},
tunnelDefect: (defect) => defect instanceof DeclinedError,
}).execute(code),
)

const decline = () => Effect.die(new DeclinedError())

const tunneledDefect = (exit: Exit.Exit<CodeMode.Result, never>) => {
if (exit._tag !== "Failure") return undefined
return exit.cause.reasons.flatMap((reason) => (Cause.isDieReason(reason) ? [reason.defect] : []))[0]
}

describe("CodeMode host defect tunnel", () => {
test("re-raises a tunneled defect instead of reporting a tool diagnostic", async () => {
const exit = await declineRuntime("return await tools.host.call({})", decline)

expect(tunneledDefect(exit)).toBeInstanceOf(DeclinedError)
})

test("program try/catch cannot swallow a tunneled defect", async () => {
const exit = await declineRuntime(
`
try {
await tools.host.call({})
return "reached"
} catch (error) {
return "caught"
} finally {
const ignored = 1
}
`,
decline,
)

expect(tunneledDefect(exit)).toBeInstanceOf(DeclinedError)
})

test("a tunneled defect from an un-awaited call still aborts the execution", async () => {
const exit = await declineRuntime(
`
tools.host.call({})
return "done"
`,
decline,
)

expect(tunneledDefect(exit)).toBeInstanceOf(DeclinedError)
})

test("leaves unmatched defects sanitized", async () => {
const exit = await declineRuntime("return await tools.host.call({})", () =>
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
)

expect(exit._tag).toBe("Success")
if (exit._tag !== "Success" || exit.value.ok) throw new Error("expected a sanitized diagnostic")
expect(exit.value.error).toStrictEqual({ kind: "ToolFailure", message: "Tool execution failed" })
expect(JSON.stringify(exit.value)).not.toMatch(/defect-secret/)
})
})

describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/codemode/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as CodeModeTool from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { Decline } from "../decline"
import { definition } from "../tool/runtime"

const ExecuteFile = Schema.Struct({
Expand Down Expand Up @@ -41,6 +42,18 @@ const description = [
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n")

/**
* The runtime itself defaults every limit to unlimited, so the ceiling has to come from the host:
* without one, model-authored code can spin forever, fan out over tools without bound, or return
* an unbounded payload. These are ceilings, not budgets — normal programs never reach them — and
* each is overridable through the `codemode` config key.
*/
export const DEFAULT_LIMITS = {
timeoutMs: 120_000,
maxToolCalls: 100,
maxOutputBytes: 1024 * 1024,
} as const satisfies CodeMode.ExecutionLimits

export const create = (
registrations: ReadonlyMap<string, Info>,
executeTool: (
Expand All @@ -49,6 +62,7 @@ export const create = (
input: unknown,
context: Context,
) => Effect.Effect<Result, Error>,
limits: CodeMode.ExecutionLimits = DEFAULT_LIMITS,
) => {
return ({
name: "execute",
Expand Down Expand Up @@ -103,6 +117,7 @@ export const create = (
})
},
},
limits,
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
Expand Down Expand Up @@ -145,6 +160,7 @@ function runtime(
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
limits?: CodeMode.ExecutionLimits,
) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
Expand All @@ -161,7 +177,10 @@ function runtime(
execute: (input) => executeTool(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
// Declines reach the runtime as defects. Without the tunnel they would be sanitized into a
// generic "Tool execution failed" and the model would keep going, so a refusal inside `execute`
// would be weaker than the same refusal on a direct call.
return CodeMode.make<typeof tools>({ tools, tunnelDefect: Decline.is, ...hooks, ...(limits ? { limits } : {}) })
}

// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCodeMode } from "./config/codemode"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
Expand Down Expand Up @@ -91,6 +92,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
codemode: ConfigCodeMode.Info.pipe(Schema.optional).annotate({
description: "Code Mode execution limits",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/codemode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export * as ConfigCodeMode from "./codemode"

import { Schema } from "effect"
import { NonNegativeInt, PositiveInt } from "../schema"

export class Info extends Schema.Class<Info>("Config.CodeMode")({
timeout_ms: PositiveInt.pipe(Schema.optional).annotate({
description: "Wall-clock milliseconds one Code Mode program may run before it is interrupted. Defaults to 120000.",
}),
max_tool_calls: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum tool calls one Code Mode program may admit, including search. Defaults to 100.",
}),
max_output_bytes: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum UTF-8 bytes retained from a Code Mode program's result and logs. Defaults to 1048576.",
}),
}) {}
16 changes: 16 additions & 0 deletions packages/core/src/decline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export * as Decline from "./decline"

import { Permission } from "./permission"
import { QuestionTool } from "./tool/plugin/question"

/**
* A user's refusal. Leaves raise these as defects on purpose (the tunnel entered in
* `Permission.assert` and the question tool) so the blanket `mapError` wrapping tool execution
* cannot turn a "no" into model-facing tool output. They resurface as typed failures exactly once,
* at the seam the session runner executes through.
*/
export type Error = Permission.DeclinedError | QuestionTool.CancelledError

/** Single definition of what counts as a tunneled decline, shared by every consumer of the tunnel. */
export const is = (value: unknown): value is Error =>
value instanceof Permission.DeclinedError || value instanceof QuestionTool.CancelledError
13 changes: 5 additions & 8 deletions packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Decline } from "../decline"
import { Model } from "../model"
import { Permission } from "../permission"
import { PluginHooks } from "../plugin/hooks"
import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
Expand All @@ -18,18 +17,16 @@ import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"

/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
export type ExecuteError = Tool.Error | Decline.Error

// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
// here at the seam the runner executes through — including declines raised inside a Code Mode
// program, which the Code Mode host re-raises as the same defect.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
Cause.isDieReason(reason) && Decline.is(reason.defect) ? [reason.defect] : [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
Expand Down
Loading
Loading