diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md index bc72c815ea11..94c3fcb2d7f4 100644 --- a/packages/codemode/AGENTS.md +++ b/packages/codemode/AGENTS.md @@ -4,7 +4,7 @@ - Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool. - Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it. - Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens. -- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes. +- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. ## OpenAPI diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md deleted file mode 100644 index f26c0446efa5..000000000000 --- a/packages/codemode/codemode.md +++ /dev/null @@ -1,164 +0,0 @@ -# CodeMode Design and Status - -This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter. -It records current behavior, intentional boundaries, durable rationale, and material remaining work. - -Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove -completed work instead of preserving checked-off chronology. - -Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives -in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in -[src/openapi/TODO.md](./src/openapi/TODO.md). - -## How CodeMode Works - -### Purpose - -CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model -can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently, -and filter or aggregate results before returning them to the agent loop. - -The goals are: - -- Reduce model context consumed by large tool catalogs. -- Avoid an agent round-trip between every dependent tool call. -- Keep large intermediate results inside the program instead of sending them through model context. -- Give generated code only the authority explicitly supplied by the host. - -CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system. - -### Runtime - -The generic runtime lives in `packages/codemode` and is host-neutral: - -1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`. -2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in. -3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter - executes it without `eval`. -4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side. -5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption - remains Effect interruption. - -Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not -validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is -advertised as `Promise`. - -### Discovery and model workflow - -The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected -round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is -always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is -partial. - -The intended workflow is: - -1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the - next execution. -2. Call the exact returned path without guessing or normalizing segments. -3. Narrow `Promise` results before reading fields. -4. Start independent calls together and await them with `Promise.all`. -5. Filter and aggregate inside the program, then return only the data needed by the model. - -Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact -path lookup, namespace browsing, deterministic ranking, and pagination. - -### Tool execution - -Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls, -async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the -`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime -of work they started. -Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler. -`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers -continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the -executor first-class resolve/reject callables that may escape and settle the promise later, exactly once. -Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach -order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count -parity beyond that. At normal completion CodeMode interrupts everything still running - race losers, -fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can -exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead -would let it hold the execution open indefinitely. -Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or -host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the -same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than -discarded. CodeMode does not limit tool-call concurrency. - -The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no -defaults because budgets are host policy. The interpreter also enforces a fixed internal data nesting depth. -`maxOutputBytes` bounds retained payload bytes, not the complete rendered message; -warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and -host-added framing are intentionally outside the budgets. - -### Data, files, and failures - -Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and -Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the -boundary. - -Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a -tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid -data, tool failures, limits, timeouts, execution failures, and warning truncation. - -Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and -attach them to the outer result, but the program receives only the structured tool output. - -### V2 OpenCode adapter - -CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and -`packages/core/src/tool/execute.ts`: - -- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through - `Tools.Service`. -- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools - normally. -- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become - CodeMode namespaces instead of flattened model-facing names. -- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later - requests. -- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution - authorization. -- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result. -- Nested call statuses are returned as final `execute` metadata for the TUI. -- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently - run registry hooks or model-output bounding; this keeps complete intermediate structured values available for - in-program filtering. The outer `execute` settlement is the single model-output bounding boundary. -- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its - supervised children; the outer settlement applies Core's normal output-retention policy. - -MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing -output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals -inside CodeMode. - -## Intentionally Unsupported - -These are product boundaries rather than DSL backlog: - -- Ambient filesystem, process, environment, network, credential, or application access. External work must go through - supplied tools. -- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation. -- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external - side effects. Hosts and tools own those concerns. -- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents. - -The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot -represent accurately rather than guessing semantics. - -## Decisions and Rationale - -| Decision | Rationale | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. | -| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. | -| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. | -| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. | -| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. | -| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. | -| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. | -| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. | -| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. | -| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. | - -## Remaining Work - -The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness, -diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md). diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index b33c20c1ab99..1c975040dc62 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -52,9 +52,6 @@ export const Flag = { get OPENCODE_EXPERIMENTAL_REFERENCES() { return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES") }, - get CODEMODE_ENABLED() { - return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED") - }, get OPENCODE_TUI_CONFIG() { return process.env["OPENCODE_TUI_CONFIG"] }, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 4be0d412f5c9..2e7f34b0474e 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -1,7 +1,6 @@ export * as McpGuidance from "./guidance" import { makeLocationNode } from "../effect/app-node" -import { Flag } from "../flag/flag" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -18,11 +17,7 @@ type Summary = typeof Summary.Type const entries = (servers: ReadonlyArray) => servers.flatMap((server) => [ ` `, - ...(Flag.CODEMODE_ENABLED - ? [ - ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, - ] - : []), + ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, ...server.instructions.split("\n").map((line) => ` ${line}`), " ", ]) @@ -81,7 +76,7 @@ export const layer = Layer.effect( removed: () => "MCP server instructions are no longer available.", }, }) - if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") + if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") return source(Instructions.removed) const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { concurrency: "unbounded", @@ -90,12 +85,9 @@ export const layer = Layer.effect( const visible = instructions .filter((item) => { const owned = tools.filter((tool) => tool.server === item.server) - return ( - (!Flag.CODEMODE_ENABLED && owned.length === 0) || - owned.some( - (tool) => - PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", - ) + return owned.some( + (tool) => + PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", ) }) .map((item) => ({ server: item.server, instructions: item.instructions })) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 9bfd05f1a4c8..08c9fabefe2d 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -29,7 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe ## Registration Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a -group, which flattens direct model names to `_`, and may be deferred from direct model exposure. +group, which flattens direct model names to `_`, and default into CodeMode (`codemode` defaults true; +`codemode: false` keeps the tool on the provider's native tool list). Registrations are scoped: diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 927cd3098dc4..436a60e7cea0 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -212,6 +212,7 @@ export const Plugin = { }), "edit", ), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 826b1ca4fad5..89672dbfc6c9 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -58,16 +58,16 @@ export const create = (registrations: ReadonlyMap) => { }) if (registration.group === undefined) { const path = registration.name - if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`) + if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`) tools[path] = value continue } const path = registration.name const namespace = registration.group const group = tools[namespace] - if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`) + if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`) if (group) { - if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`) + if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`) group[path] = value continue } diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index b047101c3902..3cde812aaa9c 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -106,6 +106,7 @@ export const Plugin = { ), ), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 937963f4ca79..cdf61866c6d1 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -137,6 +137,7 @@ export const Plugin = { ), ), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 7503e4e94e53..8aad746ed627 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event" import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" import { EventV2 } from "../event" -import { Flag } from "../flag/flag" + import { MCP } from "../mcp" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard( const next = yield* Scope.fork(scope) yield* Effect.forEach( groups, - ([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }), + ([group, record]) => tools.register(record, { group }), { discard: true, }, diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 843f4df8c3a6..4d7c3030b98a 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -191,6 +191,7 @@ export const Plugin = { }), "edit", ), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index ec2b1938a063..41c9d2dd50ee 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -106,6 +106,7 @@ export const Plugin = { }), ), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index b71680142bea..aa2e81b56457 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -139,6 +139,7 @@ export const Plugin = { ) }, }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0429d7ba44be..b1087c782284 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" import type { AgentV2 } from "../agent" -import { Flag } from "../flag/flag" import { PermissionV2 } from "../permission" import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" @@ -56,7 +55,7 @@ const registryLayer = Layer.effect( readonly tool: AnyTool readonly name: string readonly group?: string - readonly deferred: boolean + readonly codemode: boolean } const local = new Map>() @@ -132,7 +131,8 @@ const registryLayer = Layer.effect( register: Effect.fn("ToolRegistry.register")(function* (tools, options) { const entries = registrationEntries(tools, options?.group) if (entries.length === 0) return - const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute") + const codemode = options?.codemode ?? true + const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") if (reserved) return yield* Effect.fail( new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), @@ -149,7 +149,7 @@ const registryLayer = Layer.effect( tool: entry.tool, name: entry.name, group: entry.group, - deferred: options?.deferred ?? false, + codemode, }, }, ]) @@ -168,18 +168,17 @@ const registryLayer = Layer.effect( }), materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) { const direct = new Map() - const deferred = new Map() + const codemode = new Map() const rules = permissions ?? [] for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - if (registration.deferred && !Flag.CODEMODE_ENABLED) continue if (whollyDisabled(permission(registration.tool, name), rules)) continue - if (registration.deferred) deferred.set(name, registration) + if (registration.codemode) codemode.set(name, registration) else direct.set(name, registration) } const execute = - deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined + codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined return { definitions: [ ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index d2b8bb474482..294d14e1b5dd 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -276,6 +276,7 @@ export const Plugin = { ), ), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1f80bd9684a3..2589fd08ad90 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -97,6 +97,7 @@ export const Plugin = { }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) }), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 9b7015935a70..6fcda996dce6 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -208,6 +208,7 @@ export const Plugin = { return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } }), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d76164593916..3b900e178422 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -174,6 +174,7 @@ export const Plugin = { } }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 9c91d84f921a..ab65c33a4cf9 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -252,6 +252,7 @@ export const Plugin = { ) }, }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index a88063f4e2a5..d7cd8c54f4cd 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -91,6 +91,7 @@ export const Plugin = { }), "edit", ), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 8c2db479e5a2..6d9e0aac3dc5 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -260,6 +260,7 @@ describe("PluginV2", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }), + { codemode: false }, ), ) .pipe(Effect.orDie), @@ -273,7 +274,7 @@ describe("PluginV2", () => { }), ) - it.effect("groups tool names and defers registrations from direct exposure", () => + it.effect("groups tool names and routes codemode registrations through execute", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service @@ -289,9 +290,9 @@ describe("PluginV2", () => { effect: (ctx) => ctx.tool .transform((draft) => { - draft.add("plain", tool("Plain")) - draft.add("look/up", tool("Lookup"), { group: "context 7" }) - draft.add("search", tool("Search"), { group: "context 7", deferred: true }) + draft.add("plain", tool("Plain"), { codemode: false }) + draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false }) + draft.add("search", tool("Search"), { group: "context 7" }) }) .pipe(Effect.orDie), }) @@ -330,6 +331,7 @@ describe("PluginV2", () => { output: Schema.Struct({ text: Schema.String }), execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), }), + { codemode: false }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 433341d39894..afe72f6b59f7 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -135,6 +135,7 @@ describe("fromPromise", () => { await ctx.tool.transform((tools) => { tools.add({ name: "hello", + options: { codemode: false }, description: "Hello", input: Schema.Struct({ name: Schema.String }), output: Schema.String, diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 8c14b2aebff2..b7351cd90098 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -70,7 +70,7 @@ describe("ToolRegistry", () => { bash: make(), edit: make("edit"), write: make("edit"), - }) + }, { codemode: false }) const names = (permissions: PermissionV2.Ruleset) => toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) @@ -95,8 +95,8 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service const shared = make() - yield* service.register({ first: shared }) - yield* service.register({ second: Tool.withPermission(shared, "edit") }) + yield* service.register({ first: shared }, { codemode: false }) + yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false }) Tool.withPermission(shared, "question") expect( @@ -110,7 +110,7 @@ describe("ToolRegistry", () => { it.effect("reuses model definitions across requests", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ echo: make() }) + yield* service.register({ echo: make() }, { codemode: false }) const first = yield* toolDefinitions(service) const second = yield* toolDefinitions(service) @@ -122,7 +122,7 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service const scope = yield* Scope.make() - yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) + yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope)) expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"]) yield* Scope.close(scope, Exit.void) expect(yield* toolDefinitions(service)).toEqual([]) @@ -135,7 +135,7 @@ describe("ToolRegistry", () => { const scope = yield* Scope.make() const registered = yield* Deferred.make() const fiber = yield* service - .register({ echo: make() }) + .register({ echo: make() }, { codemode: false }) .pipe( Effect.andThen(Deferred.succeed(registered, undefined)), Effect.andThen(Effect.never), @@ -161,7 +161,7 @@ describe("ToolRegistry", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), }), - }) + }, { codemode: false }) expect( yield* executeTool(service, { sessionID, @@ -184,7 +184,7 @@ describe("ToolRegistry", () => { output: Schema.Struct({}), execute: () => Effect.die("unexpected executor defect"), }), - }) + }, { codemode: false }) expect( yield* service.materialize().pipe( Effect.flatMap((materialized) => @@ -203,7 +203,7 @@ describe("ToolRegistry", () => { it.effect("propagates retention failures through settlement", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ echo: make() }) + yield* service.register({ echo: make() }, { codemode: false }) const materialized = yield* service.materialize() const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit) @@ -234,7 +234,7 @@ describe("ToolRegistry", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), }), - }) + }, { codemode: false }) yield* executeTool(service, { sessionID, ...identity, @@ -248,7 +248,7 @@ describe("ToolRegistry", () => { Effect.gen(function* () { bounds.length = 0 const service = yield* ToolRegistry.Service - yield* service.register({ bounded: make() }) + yield* service.register({ bounded: make() }, { codemode: false }) expect( yield* settleTool(service, { sessionID, @@ -282,7 +282,7 @@ describe("ToolRegistry", () => { execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], }), - }) + }, { codemode: false }) expect( yield* executeTool(service, { @@ -319,7 +319,7 @@ describe("ToolRegistry", () => { }), execute: () => Effect.succeed({ value: "invalid" }), }), - }) + }, { codemode: false }) expect( yield* executeTool(service, { sessionID, @@ -334,10 +334,10 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service const scope = yield* Scope.make() - yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope)) + yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope)) const request = yield* service.materialize() yield* Scope.close(scope, Exit.void) - yield* service.register({ echo: constant("replacement") }) + yield* service.register({ echo: constant("replacement") }, { codemode: false }) expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" }) expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" }) @@ -347,9 +347,9 @@ describe("ToolRegistry", () => { it.effect("reveals the previous registration after an overlay closes", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ echo: constant("base") }) + yield* service.register({ echo: constant("base") }, { codemode: false }) const overlay = yield* Scope.make() - yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay)) + yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay)) expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" }) yield* Scope.close(overlay, Exit.void) @@ -357,37 +357,31 @@ describe("ToolRegistry", () => { }), ) - it.effect("executes deferred tools advertised in a model request", () => + it.effect("executes codemode tools advertised in a model request", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const executed: string[] = [] const scope = yield* Scope.make() yield* service - .register( - { - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })), - }), - }, - { deferred: true }, - ) - .pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() - yield* Scope.close(scope, Exit.void) - yield* service.register( - { + .register({ echo: Tool.make({ description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })), + execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })), }), - }, - { deferred: true }, - ) + }) + .pipe(Scope.provide(scope)) + const materialized = yield* service.materialize() + yield* Scope.close(scope, Exit.void) + yield* service.register({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })), + }), + }) const settlement = yield* materialized.settle({ ...call("execute"), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6b7316c6ed96..63171b9a44e5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -261,7 +261,7 @@ const echo = Layer.effectDiscard( output: Schema.Any, execute: () => Effect.succeed({ big: 1n }), }), - }), + }, { codemode: false }), ), ) const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) @@ -782,7 +782,7 @@ describe("SessionRunnerLLM", () => { return { answer: query.toUpperCase() } }), }), - }) + }, { codemode: false }) yield* admit(session, "Use application context") responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] @@ -827,7 +827,7 @@ describe("SessionRunnerLLM", () => { output: Schema.Struct({ value: Schema.String }), execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })), }), - }) + }, { codemode: false }) .pipe(Scope.provide(scope)) yield* admit(session, "Use the reloaded tool") responses = [ @@ -852,7 +852,7 @@ describe("SessionRunnerLLM", () => { output: Schema.Struct({ value: Schema.String }), execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })), }), - }) + }, { codemode: false }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) @@ -3189,7 +3189,7 @@ describe("SessionRunnerLLM", () => { Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), ), }), - }) + }, { codemode: false }) yield* admit(session, "Call blocked") responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] @@ -3221,7 +3221,7 @@ describe("SessionRunnerLLM", () => { output: Schema.Struct({}), execute: () => Effect.die(new PermissionV2.DeclinedError()), }), - }) + }, { codemode: false }) yield* admit(session, "Call declined") response = reply.tool("call-declined", "declined", {}) @@ -3261,7 +3261,7 @@ describe("SessionRunnerLLM", () => { Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), ), }), - }) + }, { codemode: false }) yield* admit(session, "Call corrected") responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] @@ -3321,7 +3321,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ permissionfail: permissionFail }) + yield* registry.register({ permissionfail: permissionFail }, { codemode: false }) yield* admit(session, "Reject permission") responses = [ reply.tool("call-permission", "permissionfail", {}), @@ -3366,7 +3366,7 @@ describe("SessionRunnerLLM", () => { output: Schema.Struct({}), execute: () => Effect.die(new QuestionTool.CancelledError()), }), - }) + }, { codemode: false }) yield* admit(session, "Ask then stop") responses = [reply.tool("call-question", "question", {}), []] diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index 08c8ce4decad..ad9cff8a3e9e 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -315,11 +315,12 @@ export default Plugin.define({ Unsupported characters in tool and group names are normalized to underscores. The resulting exposed key must begin with a letter and contain at most 64 letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, deferred }`: +configure registration with `{ group, codemode }`: - `group` prefixes and groups the exposed tool name. -- `deferred: true` makes the tool available through the deferred `execute` - tool instead of exposing it directly. +- `codemode` defaults to `true` and makes the tool available through the + `execute` CodeMode tool. Set `codemode: false` to expose it directly to the + provider. The executor receives a second context argument containing `sessionID`, `agent`, `assistantMessageID`, and `toolCallID`. diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 7ba420d7a2a0..ce53b78bd386 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -272,7 +272,8 @@ export interface ToolExecuteAfterEvent { export interface RegisterOptions { readonly group?: string - readonly deferred?: boolean + /** Defaults to true. False exposes the tool directly to the provider. */ + readonly codemode?: boolean } export interface ToolDraft { diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index c804f1d1ebd6..77d936f5800f 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -69,7 +69,8 @@ export interface ToolExecuteAfterEvent { export interface RegisterOptions { readonly group?: string - readonly deferred?: boolean + /** Defaults to true. False exposes the tool directly to the provider. */ + readonly codemode?: boolean } export interface ToolDraft {