diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 1f99c01e74..35a96c4a7e 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -120,6 +120,13 @@ export type Runtime = { readonly execute: (code: string) => Effect.Effect } +export const description = [ + "Run JavaScript in a confined Code Mode runtime through { code }.", + "Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.", + "Use `search({ query })` to discover exact signatures when needed.", + "Await important calls and use `Promise.all` for independent calls.", +].join("\n") + const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 295a8f1db0..8706f9377a 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -500,6 +500,23 @@ describe("CodeMode schema flexibility", () => { }) describe("CodeMode public contract", () => { + test("keeps the tool description independent from the catalog", () => { + const runtime = CodeMode.make({ + tools: { + lookup: Tool.make({ + description: "Look up a record", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed("found"), + }), + }, + }) + + expect(CodeMode.description).toContain("confined Code Mode runtime") + expect(CodeMode.description).not.toContain("lookup") + expect(runtime.instructions()).toContain("tools.lookup(input:") + }) + const lookup = Tool.make({ description: "Look up an order by ID", input: Schema.Struct({ id: Schema.String }), diff --git a/packages/core/src/codemode.ts b/packages/core/src/codemode.ts new file mode 100644 index 0000000000..6fb0b71f81 --- /dev/null +++ b/packages/core/src/codemode.ts @@ -0,0 +1,80 @@ +export * as CodeMode from "./codemode" + +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "./effect/app-node" +import { PermissionV2 } from "./permission" +import { ExecuteTool } from "./tool/execute" +import { permission, registrationEntries, type AnyTool } from "./tool/tool" +import { Tools } from "./tool/tools" +import { Wildcard } from "./util/wildcard" + +export interface Materialization { + readonly tool?: AnyTool + readonly instructions?: string +} + +export interface Interface { + readonly register: ( + tools: Readonly>, + options?: Tools.RegisterOptions, + ) => Effect.Effect + readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/CodeMode") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const local = new Map< + string, + Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }> + >() + + return Service.of({ + register: Effect.fn("CodeMode.register")(function* (tools, options) { + const entries = registrationEntries(tools, options?.namespace) + if (entries.length === 0) return + yield* Effect.uninterruptible( + Effect.gen(function* () { + const token = {} + for (const entry of entries) + local.set(entry.key, [ + ...(local.get(entry.key) ?? []), + { token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } }, + ]) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const entry of entries) { + const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? [] + if (registrations.length > 0) local.set(entry.key, registrations) + else local.delete(entry.key) + } + }), + ) + }), + ) + }), + materialize: Effect.fn("CodeMode.materialize")(function* (permissions) { + const registrations = new Map() + const rules = permissions ?? [] + for (const [name, entries] of local) { + const registration = entries.at(-1)?.registration + if (!registration) continue + const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action)) + if (rule?.resource === "*" && rule.effect === "deny") continue + registrations.set(name, registration) + } + if (registrations.size === 0) return {} + const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action)) + if (executeRule?.resource === "*" && executeRule.effect === "deny") return {} + return { + tool: ExecuteTool.create(registrations), + instructions: ExecuteTool.instructions(registrations), + } + }), + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts new file mode 100644 index 0000000000..621e678dbb --- /dev/null +++ b/packages/core/src/codemode/instructions.ts @@ -0,0 +1,45 @@ +export * as CodeModeInstructions from "./instructions" + +import { Context, Effect, Layer, Schema } from "effect" +import { AgentV2 } from "../agent" +import { CodeMode } from "../codemode" +import { makeLocationNode } from "../effect/app-node" +import { Instructions } from "../instructions/index" + +export interface Interface { + readonly load: (agent: AgentV2.Selection) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/CodeModeInstructions") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const codeMode = yield* CodeMode.Service + + return Service.of({ + load: Effect.fn("CodeModeInstructions.load")(function* (selection) { + const instructions = selection.info + ? (yield* codeMode.materialize(selection.info.permissions)).instructions + : undefined + return Instructions.make({ + key: Instructions.Key.make("core/codemode"), + codec: Schema.toCodecJson(Schema.String), + read: Effect.succeed(instructions ?? Instructions.removed), + render: { + initial: (current) => current, + changed: (_previous, current) => + [ + "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.", + current, + ].join("\n\n"), + removed: () => + "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", + }, + }) + }), + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] }) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 47e3f6dd2f..de71c0f365 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -2,6 +2,8 @@ import { Effect, Layer, LayerMap } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" +import { CodeMode } from "./codemode" +import { CodeModeInstructions } from "./codemode/instructions" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" @@ -66,6 +68,7 @@ const locationServiceNodes = [ Pty.node, Shell.node, SkillV2.node, + CodeMode.node, InstructionBuiltIns.node, InstructionDiscovery.node, LocationMutation.node, @@ -77,6 +80,7 @@ const locationServiceNodes = [ ToolRegistry.toolsNode, Image.node, SkillInstructions.node, + CodeModeInstructions.node, ReferenceInstructions.node, InstructionEntry.node, Form.node, diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 9bdf354731..922f0c3bc2 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -12,6 +12,7 @@ import { McpInstructions } from "../mcp/instructions" import { PluginSupervisor } from "../plugin/supervisor" import { ReferenceInstructions } from "../reference/instructions" import { SkillInstructions } from "../skill/instructions" +import { CodeModeInstructions } from "../codemode/instructions" import { AgentNotFoundError } from "./error" import { SessionHistory } from "./history" import { InstructionEntry } from "./instruction-entry" @@ -54,6 +55,7 @@ const layer = Layer.effect( Effect.gen(function* () { const agents = yield* AgentV2.Service const builtins = yield* InstructionBuiltIns.Service + const codeModeInstructions = yield* CodeModeInstructions.Service const db = (yield* Database.Service).db const discovery = yield* InstructionDiscovery.Service const entries = yield* InstructionEntry.Service @@ -77,6 +79,7 @@ const layer = Layer.effect( const instructions = yield* Effect.all( [ builtins.load(sessionID), + codeModeInstructions.load(agent), discovery.load(), skillInstructions.load(agent), referenceInstructions.load(), @@ -109,6 +112,7 @@ export const node = makeLocationNode({ layer, deps: [ AgentV2.node, + CodeModeInstructions.node, Database.node, InstructionBuiltIns.node, InstructionDiscovery.node, diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index e47adbba4c..de062dd372 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -36,34 +36,15 @@ type CollectedFiles = { readonly files: Array } -interface Registration { +export interface Registration { readonly tool: AnyTool readonly name: string readonly namespace?: string } export const create = (registrations: ReadonlyMap) => { - const runtime = ( - invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, - hooks?: CodeMode.ToolCallHooks, - ) => { - const tools: Record> = {} - for (const [name, registration] of registrations) { - const child = definition(name, registration.tool) - const value = Tool.make({ - description: child.description, - input: child.inputSchema, - output: child.outputSchema, - run: (input) => invoke(name, registration, input), - }) - const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` - tools[path] = value - } - return CodeMode.make({ tools, ...hooks }) - } - const discovery = runtime(() => Effect.fail(toolError("Execute context is unavailable"))) return make({ - description: discovery.instructions(), + description: CodeMode.description, input: CodeMode.Input, output: ExecuteOutput, structured: ExecuteMetadata, @@ -92,6 +73,7 @@ export const create = (registrations: ReadonlyMap) => { ), ) const result = yield* runtime( + registrations, (name, registration, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) @@ -141,6 +123,29 @@ export const create = (registrations: ReadonlyMap) => { }) } +export const instructions = (registrations: ReadonlyMap) => { + return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions() +} + +function runtime( + registrations: ReadonlyMap, + invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, + hooks?: CodeMode.ToolCallHooks, +) { + const tools: Record> = {} + for (const [name, registration] of registrations) { + const child = definition(name, registration.tool) + const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` + tools[path] = Tool.make({ + description: child.description, + input: child.inputSchema, + output: child.outputSchema, + run: (input) => invoke(name, registration, input), + }) + } + return CodeMode.make({ tools, ...hooks }) +} + function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return if (typeof input !== "object" || Array.isArray(input)) return { input } diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 844e4896db..6a19d18179 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -9,7 +9,7 @@ import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { ExecuteTool } from "./execute" +import { CodeMode } from "../codemode" import { definition, permission, @@ -67,6 +67,7 @@ const registryLayer = Layer.effect( const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service const image = yield* Image.Service + const codeMode = yield* CodeMode.Service type NormalizedItem = ToolOutput["content"][number] | "decode" | "size" const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) { @@ -104,7 +105,6 @@ const registryLayer = Layer.effect( readonly tool: AnyTool readonly name: string readonly namespace?: string - readonly codemode: boolean } const local = new Map>() @@ -203,6 +203,7 @@ const registryLayer = Layer.effect( return yield* Effect.fail( new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), ) + if (codemode) return yield* codeMode.register(tools, options) yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} @@ -215,7 +216,6 @@ const registryLayer = Layer.effect( tool: entry.tool, name: entry.name, namespace: entry.namespace, - codemode, }, }, ]) @@ -234,17 +234,15 @@ const registryLayer = Layer.effect( }), materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) { const direct = 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 (whollyDisabled(permission(registration.tool, name), rules)) continue - if (registration.codemode) codemode.set(name, registration) - else direct.set(name, registration) + direct.set(name, registration) } - const execute = - codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined + const codeModeMaterialization = yield* codeMode.materialize(permissions) + const execute = codeModeMaterialization.tool return { definitions: [ ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), @@ -278,11 +276,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { export const node = makeLocationNode({ service: Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node, Image.node], + deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node, Image.node], + deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node], }) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts new file mode 100644 index 0000000000..695be92145 --- /dev/null +++ b/packages/core/test/codemode.test.ts @@ -0,0 +1,27 @@ +import { describe, expect } from "bun:test" +import { CodeMode } from "@opencode-ai/core/codemode" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Tool } from "@opencode-ai/core/tool/tool" +import { Effect, Schema } from "effect" +import { it } from "./lib/effect" + +describe("CodeMode", () => { + it.effect("owns registrations, execute, and catalog materialization", () => + Effect.gen(function* () { + const codeMode = yield* CodeMode.Service + yield* codeMode.register({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed(text), + }), + }) + + const materialized = yield* codeMode.materialize() + expect(materialized.tool).toBeDefined() + expect(materialized.instructions).toContain("Echo text") + expect(materialized.instructions).toContain("tools.echo(input:") + }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))), + ) +}) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts new file mode 100644 index 0000000000..172041a11d --- /dev/null +++ b/packages/core/test/codemode/instructions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect } from "bun:test" +import { AgentV2 } from "@opencode-ai/core/agent" +import { CodeMode } from "@opencode-ai/core/codemode" +import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Effect, Layer } from "effect" +import { it } from "../lib/effect" +import { readInitial, readUpdate } from "../lib/instructions" + +const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build"))) + +describe("CodeModeInstructions", () => { + it.effect("renders catalog changes and removal", () => { + let catalog: string | undefined = "Initial Code Mode catalog" + const layer = AppNodeBuilder.build(CodeModeInstructions.node, [ + [ + CodeMode.node, + Layer.mock(CodeMode.Service, { + materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }), + register: () => Effect.void, + }), + ], + ]) + + return Effect.gen(function* () { + const instructions = yield* CodeModeInstructions.Service + const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + expect(initialized.text).toBe("Initial Code Mode catalog") + + catalog = "Updated Code Mode catalog" + expect( + yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, initialized))), + ).toMatchObject({ + text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog", + }) + + catalog = undefined + expect( + yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, initialized))), + ).toMatchObject({ + text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", + }) + }).pipe(Effect.provide(layer)) + }) +}) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 015bd0e541..b6279e7438 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -638,9 +638,10 @@ it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") - const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") + const materialized = yield* registry.materialize() + const execute = materialized.definitions.find((tool) => tool.name === "execute") - expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>") + expect(execute?.description).not.toContain("tools.demo.search") }), ) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 6f7b13f6e1..a113b954ed 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -466,6 +466,9 @@ describe("ToolRegistry", () => { }) .pipe(Scope.provide(scope)) const materialized = yield* service.materialize() + const execute = materialized.definitions.find((tool) => tool.name === "execute") + expect(execute?.description).toContain("confined Code Mode runtime") + expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) yield* service.register({ echo: Tool.make({