diff --git a/packages/codemode/README.md b/packages/codemode/README.md index b43831903735..7881c4f7e4eb 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -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 diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 4b8b9001107b..a0642107202a 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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 diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 0d557169d833..2524dd0240f9 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -38,6 +38,14 @@ export type ExecuteOptions = {}> = { tools?: Provided & Tools> /** 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> /** Observes each admitted tool call as it succeeds, fails, or is interrupted. */ diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index a3f789868efe..40b57aee34c3 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -24,6 +24,19 @@ export const executeWithLimits = // 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>, limits.maxToolCalls, @@ -32,6 +45,7 @@ export const executeWithLimits = onToolCallStart: options.onToolCallStart, onToolCallEnd: options.onToolCallEnd, }, + tunnel, ) const logs: Array = [] const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) @@ -102,15 +116,20 @@ export const executeWithLimits = 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), ), diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 5200c13b26ad..d3e6078877fc 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -117,10 +117,38 @@ export class ToolRuntimeError extends Error { } } -const runHost = (effect: Effect.Effect): Effect.Effect => +/** + * 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, 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 = ( + effect: Effect.Effect, + tunnel: DefectTunnel | undefined, +): Effect.Effect => 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)) }), @@ -492,6 +520,7 @@ export const make = ( maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, + tunnel?: DefectTunnel, ): ToolRuntime => { const calls: Array = [] const root = toolTrie(tools) @@ -548,7 +577,10 @@ export const make = ( 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}'.`), diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index cac1d4602492..bf276358da4e 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -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) => @@ -159,6 +159,81 @@ describe("CodeMode host failure boundary", () => { }) }) +class DeclinedError extends Schema.TaggedErrorClass()("DeclinedError", {}) {} + +const declineRuntime = (code: string, execute: () => Effect.Effect) => + 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) => { + 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 = [] diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 7d9c5106be6a..346a843b12f7 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -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({ @@ -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, executeTool: ( @@ -49,6 +62,7 @@ export const create = ( input: unknown, context: Context, ) => Effect.Effect, + limits: CodeMode.ExecutionLimits = DEFAULT_LIMITS, ) => { return ({ name: "execute", @@ -103,6 +117,7 @@ export const create = ( }) }, }, + limits, ).execute(code) const toolCalls = yield* Ref.get(calls) const collected = (yield* Ref.get(files)) @@ -145,6 +160,7 @@ function runtime( registrations: ReadonlyMap, executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, + limits?: CodeMode.ExecutionLimits, ) { const tools: Record> = {} for (const [name, registration] of registrations) { @@ -161,7 +177,10 @@ function runtime( execute: (input) => executeTool(name, registration, input), }) } - return CodeMode.make({ 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({ tools, tunnelDefect: Decline.is, ...hooks, ...(limits ? { limits } : {}) }) } // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b24b30f27b58..82770bbccd0a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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" @@ -91,6 +92,9 @@ export class Info extends Schema.Class("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", }), diff --git a/packages/core/src/config/codemode.ts b/packages/core/src/config/codemode.ts new file mode 100644 index 000000000000..44226f7f0d7a --- /dev/null +++ b/packages/core/src/config/codemode.ts @@ -0,0 +1,16 @@ +export * as ConfigCodeMode from "./codemode" + +import { Schema } from "effect" +import { NonNegativeInt, PositiveInt } from "../schema" + +export class Info extends Schema.Class("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.", + }), +}) {} diff --git a/packages/core/src/decline.ts b/packages/core/src/decline.ts new file mode 100644 index 000000000000..c7730924cd5f --- /dev/null +++ b/packages/core/src/decline.ts @@ -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 diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 4cf3e4f8de79..431dada67faa 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -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" @@ -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) => { 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) } diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 4d0c4d8b54a1..934431c88949 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -9,6 +9,8 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import type { Agent } from "./agent" import { CodeModeCatalog } from "./codemode/catalog" import { CodeModeTool } from "./codemode/tool" +import { Config } from "./config" +import { ConfigCodeMode } from "./config/codemode" import { Image } from "./image" import { Permission } from "./permission" import { PluginHooks } from "./plugin/hooks" @@ -51,6 +53,21 @@ const layer = Layer.effect( Effect.gen(function* () { const hooks = yield* PluginHooks.Service const image = yield* Image.Service + const config = yield* Config.Service + + const codeModeLimits = Effect.fn("Tool.codeModeLimits")(function* () { + const configured: ConfigCodeMode.Info = Object.assign( + {}, + ...(yield* config.entries()).flatMap((entry) => + entry.type === "document" && entry.info.codemode ? [entry.info.codemode] : [], + ), + ) + return { + timeoutMs: configured.timeout_ms ?? CodeModeTool.DEFAULT_LIMITS.timeoutMs, + maxToolCalls: configured.max_tool_calls ?? CodeModeTool.DEFAULT_LIMITS.maxToolCalls, + maxOutputBytes: configured.max_output_bytes ?? CodeModeTool.DEFAULT_LIMITS.maxOutputBytes, + } + }) const terminalMetadata = Effect.fn("Tool.terminalMetadata")(function* ( tool: string, @@ -254,7 +271,11 @@ const layer = Layer.effect( const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action)) const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny" const codemodeTool = codemodeEnabled - ? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context)) + ? CodeModeTool.create( + codemode, + (name, tool, input, context) => executeTool(tool, name, input, context), + yield* codeModeLimits(), + ) : undefined const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined return { @@ -329,5 +350,5 @@ const normalizedEntries = (tools: ReadonlyArray) => export const node = makeLocationNode({ service: Service, layer, - deps: [PluginHooks.node, Image.node], + deps: [PluginHooks.node, Image.node, Config.node], }) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 2c3776b74121..725830d9b1ee 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -1,10 +1,60 @@ import { describe, expect } from "bun:test" +import { CodeModeTool } from "@opencode-ai/core/codemode/tool" +import { Config } from "@opencode-ai/core/config" +import { ConfigCodeMode } from "@opencode-ai/core/config/codemode" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" +import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/core/session" import { Tool } from "@opencode-ai/core/tool" -import { Effect, Schema } from "effect" +import { Cause, Effect, Exit, Layer, Schema } from "effect" import { it } from "./lib/effect" +import { toolIdentity } from "./lib/tool" + +const configLayer = (info?: ConfigCodeMode.Info) => + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed( + info === undefined + ? [] + : [new Config.Document({ type: "document", info: new Config.Info({ codemode: info }) })], + ), + }), + ) + +const node = (info?: ConfigCodeMode.Info) => + AppNodeBuilder.build(Tool.node, [ + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [Config.node, configLayer(info)], + ]) + +const toolNode = node() + +const counter = { + name: "tick", + description: "Count a call", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "ok" }), +} as const + +const runProgram = (code: string, id: string) => + Effect.gen(function* () { + const tools = yield* Tool.Service + yield* tools.transform((draft) => draft.add(counter)) + const snapshot = yield* tools.snapshot() + return yield* snapshot.execute({ + sessionID: Session.ID.make("ses_codemode_limits"), + ...toolIdentity, + call: { type: "tool-call", id, name: "execute", input: { code } }, + }) + }) + +const outputText = (result: { readonly content: ReadonlyArray }) => + result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") describe("CodeMode", () => { it.effect("owns registrations, execute, and catalog materialization", () => @@ -29,13 +79,84 @@ describe("CodeMode", () => { signature: "tools.echo(input: {\n text: string,\n}): Promise", }, ]) - }).pipe( - Effect.scoped, - Effect.provide( - AppNodeBuilder.build(Tool.node, [ - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - ]), - ), - ), + }).pipe(Effect.scoped, Effect.provide(toolNode)), + ) + + it.effect("aborts the step when a tool inside a program is declined", () => + Effect.gen(function* () { + const tools = yield* Tool.Service + yield* tools.transform((draft) => + draft.add({ + name: "restricted", + description: "Requires permission", + input: Schema.Struct({}), + output: Schema.String, + // Permission.assert raises a decline through this same defect tunnel. + execute: () => Effect.die(new Permission.DeclinedError()), + }), + ) + + const snapshot = yield* tools.snapshot() + const exit = yield* Effect.exit( + snapshot.execute({ + sessionID: Session.ID.make("ses_codemode_decline"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call-decline", + name: "execute", + input: { code: "return await tools.restricted({})" }, + }, + }), + ) + + // A decline must reach the runner as a defect, not as a completed execute result the model + // can shrug off. + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + const defects = exit.cause.reasons.flatMap((reason) => (Cause.isDieReason(reason) ? [reason.defect] : [])) + expect(defects.some((defect) => defect instanceof Permission.DeclinedError)).toBe(true) + }).pipe(Effect.scoped, Effect.provide(toolNode)), + ) +}) + +describe("CodeMode execution limits", () => { + it.effect("caps unbounded tool fan-out at the default tool-call limit", () => + Effect.gen(function* () { + const limit = CodeModeTool.DEFAULT_LIMITS.maxToolCalls + const result = yield* runProgram( + `for (let i = 0; i <= ${limit}; i++) await tools.tick({})`, + "call-default-tool-calls", + ) + + const toolCalls = result.metadata?.toolCalls + expect(result.metadata?.error).toBe(true) + expect(outputText(result)).toContain(`Execution exceeded its tool-call limit of ${limit}.`) + expect(Array.isArray(toolCalls) ? toolCalls.length : undefined).toBe(limit) + }).pipe(Effect.scoped, Effect.provide(toolNode)), + ) + + it.effect("honours a configured tool-call limit", () => + Effect.gen(function* () { + const result = yield* runProgram("for (let i = 0; i < 5; i++) await tools.tick({})", "call-configured-limit") + + expect(outputText(result)).toContain("Execution exceeded its tool-call limit of 2.") + }).pipe(Effect.scoped, Effect.provide(node(new ConfigCodeMode.Info({ max_tool_calls: 2 })))), + ) + + it.effect("honours a configured output limit", () => + Effect.gen(function* () { + const result = yield* runProgram(`return "x".repeat(500)`, "call-configured-output") + + expect(outputText(result)).toContain("exceeds the 32-byte output limit") + }).pipe(Effect.scoped, Effect.provide(node(new ConfigCodeMode.Info({ max_output_bytes: 32 })))), + ) + + it.live("interrupts a busy loop at the configured timeout", () => + Effect.gen(function* () { + const result = yield* runProgram("while (true) {}", "call-configured-timeout") + + expect(outputText(result)).toContain("Execution timed out after 25ms.") + }).pipe(Effect.scoped, Effect.provide(node(new ConfigCodeMode.Info({ timeout_ms: 25 })))), ) }) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 543938c26840..3f533a1a1017 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -8,6 +8,8 @@ import { Tool } from "@opencode-ai/core/tool" import { Effect, Schema } from "effect" import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "../fixture/mcp" const echo: CodeModeCatalog.Entry = { path: "notes.echo", @@ -83,6 +85,7 @@ describe("CodeModeInstructions", () => { }) const layer = AppNodeBuilder.build(Tool.node, [ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [Config.node, emptyConfigLayer], ]) return Effect.gen(function* () { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index b33fc860d752..50491ef9fb8f 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -34,6 +34,7 @@ import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool" +import { emptyConfigLayer } from "./fixture/mcp" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void @@ -300,6 +301,7 @@ const it = testEffect( [Permission.node, permissions], [Bus.node, events], [Image.node, imagePassthrough], + [Config.node, emptyConfigLayer], ]), ) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 37a32fa4c7ac..0fb505e59c7d 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -10,6 +10,8 @@ import type { Info } from "@opencode-ai/schema/tool" import { executeTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const imageStore = Layer.mock(Image.Service, { normalize: (resource, content) => { @@ -29,7 +31,10 @@ const imageStore = Layer.mock(Image.Service, { return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" }) }, }) -const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]]) +const registryLayer = AppNodeBuilder.build(Tool.node, [ + [Image.node, imageStore], + [Config.node, emptyConfigLayer], +]) const it = testEffect(registryLayer) const identity = { agent: Agent.ID.make("build"), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index a22ba21f3938..a419d76660d0 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -18,6 +18,8 @@ import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const editToolNode = makeLocationNode({ name: "test/edit-tool-plugin", @@ -110,6 +112,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [FSUtil.node, filesystem], [Location.node, activeLocation], [Permission.node, permission], + [Config.node, emptyConfigLayer], ], ), ), diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 471bf9e795c3..b4d38b65f2f6 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -17,6 +17,8 @@ import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", @@ -136,6 +138,7 @@ const withTool = ( [FSUtil.node, filesystem], [Location.node, activeLocation], [Permission.node, permission], + [Config.node, emptyConfigLayer], ]), ), ) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 8cbda1118d54..559416065277 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -12,6 +12,8 @@ import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const sessionID = Session.ID.make("ses_question_tool_test") const assertions: Permission.AssertInput[] = [] @@ -85,6 +87,7 @@ const it = testEffect( [Permission.node, permission], [Form.node, form], [Image.node, imagePassthrough], + [Config.node, emptyConfigLayer], ]), ) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index b88f4e7e881d..997796a42fec 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -20,6 +20,8 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", @@ -71,6 +73,7 @@ const withTools = ( }), ), ], + [Config.node, emptyConfigLayer], ]), ), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 842b5ea6aae9..ececf55f43e3 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -3,6 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Config } from "@opencode-ai/core/config" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -10,6 +11,7 @@ import { Session } from "@opencode-ai/core/session" import { Skill } from "@opencode-ai/core/skill" import { SkillTool } from "@opencode-ai/core/tool/plugin/skill" import { Tool } from "@opencode-ai/core/tool" +import { emptyConfigLayer } from "./fixture/mcp" import { tmpdir } from "./fixture/tmpdir" import { Image } from "@opencode-ai/core/image" import { it } from "./lib/effect" @@ -91,6 +93,7 @@ describe("SkillTool", () => { [Permission.node, permission], [Skill.node, skills], [Image.node, imagePassthrough], + [Config.node, emptyConfigLayer], ], ) diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 315272d42da4..a1777ee9c99e 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -3,6 +3,7 @@ import { Duration, Effect, Fiber, Layer, Schema } from "effect" import * as TestClock from "effect/testing/TestClock" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Config } from "@opencode-ai/core/config" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { Permission } from "@opencode-ai/core/permission" @@ -11,6 +12,7 @@ import { Tool } from "@opencode-ai/core/tool" import { WebFetchTool } from "@opencode-ai/core/tool/plugin/webfetch" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Image } from "@opencode-ai/core/image" +import { emptyConfigLayer } from "./fixture/mcp" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" @@ -51,6 +53,7 @@ const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [ [Permission.node, permission], [Image.node, imagePassthrough], + [Config.node, emptyConfigLayer], ...replacements, ]) const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 9bd6815f22ae..673704cfdde5 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Config } from "@opencode-ai/core/config" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Permission } from "@opencode-ai/core/permission" import { Form } from "@opencode-ai/core/form" @@ -13,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" +import { emptyConfigLayer } from "./fixture/mcp" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { webSearchHost } from "./plugin/host" @@ -98,6 +100,7 @@ const it = testEffect( [Form.node, form], [KV.node, kv], [Image.node, imagePassthrough], + [Config.node, emptyConfigLayer], ], ), ) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index cd2781189f39..be50e7c4af91 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -18,6 +18,8 @@ import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { Config } from "@opencode-ai/core/config" +import { emptyConfigLayer } from "./fixture/mcp" const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", @@ -94,6 +96,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [FSUtil.node, filesystem], [Location.node, activeLocation], [Permission.node, permission], + [Config.node, emptyConfigLayer], ], ), ),