From df8e194c4e6cb06d30f52c973443eb17436eed2a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 15:28:13 -0500 Subject: [PATCH 1/4] refactor(core): replace deferred tool option with codemode Tools default into CodeMode; set codemode: false for native provider tools. Built-ins opt out; MCP follows CODEMODE_ENABLED. Adds the V2 CodeMode catalog design handoff for the stacked follow-ups. --- packages/codemode/codemode.md | 14 +- packages/core/src/tool/AGENTS.md | 3 +- packages/core/src/tool/edit.ts | 1 + packages/core/src/tool/execute.ts | 6 +- packages/core/src/tool/glob.ts | 1 + packages/core/src/tool/grep.ts | 1 + packages/core/src/tool/mcp.ts | 2 +- packages/core/src/tool/patch.ts | 1 + packages/core/src/tool/question.ts | 1 + packages/core/src/tool/read.ts | 1 + packages/core/src/tool/registry.ts | 15 +- packages/core/src/tool/shell.ts | 1 + packages/core/src/tool/skill.ts | 1 + packages/core/src/tool/subagent.ts | 1 + packages/core/src/tool/webfetch.ts | 1 + packages/core/src/tool/websearch.ts | 1 + packages/core/src/tool/write.ts | 1 + packages/core/test/plugin.test.ts | 10 +- .../test/session-runner-tool-registry.test.ts | 70 +- packages/core/test/session-runner.test.ts | 18 +- packages/docs/build/plugins.mdx | 7 +- packages/plugin/src/v2/effect/tool.ts | 3 +- packages/plugin/src/v2/promise/tool.ts | 3 +- specs/v2/codemode.md | 942 ++++++++++++++++++ 24 files changed, 1030 insertions(+), 75 deletions(-) create mode 100644 specs/v2/codemode.md diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 22092c533e61..4b74cd05bdca 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -107,11 +107,11 @@ attach them to the outer result, but the program receives only the structured to 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 +- Core has one canonical `Tool` representation. Location-scoped producers register tools through `Tools.Service`. Tools + default into CodeMode (`codemode` defaults true); `codemode: false` keeps a tool on the provider's native tool list. +- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes native tools normally. -- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become +- When visible CodeMode tools exist, Core reserves and materializes one `execute` tool. Grouped CodeMode 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. @@ -125,9 +125,9 @@ CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and - 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. +MCP tools use this canonical path: they register as grouped CodeMode tools while CodeMode is enabled. Existing output +schemas are preserved in generated signatures. Native Core tools remain native and are not ambient globals inside +CodeMode. ## Intentionally Unsupported 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..3f69ce51fc3c 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -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, codemode: Flag.CODEMODE_ENABLED }), { 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..3807d099bca1 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -56,7 +56,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 +132,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 +150,7 @@ const registryLayer = Layer.effect( tool: entry.tool, name: entry.name, group: entry.group, - deferred: options?.deferred ?? false, + codemode, }, }, ]) @@ -168,18 +169,18 @@ 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 (registration.codemode && !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/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 { diff --git a/specs/v2/codemode.md b/specs/v2/codemode.md new file mode 100644 index 000000000000..4eff8f2f219b --- /dev/null +++ b/specs/v2/codemode.md @@ -0,0 +1,942 @@ +# Detailed Handoff: OpenCode V2 CodeMode Tool Catalog + +## Purpose + +This document captures the complete gang-grill outcome for redesigning V2 Tool registration, CodeMode catalog presentation, namespace semantics, and dynamic Instructions. It is intended to let a fresh implementation agent proceed without access to the original Slack conversation. + +This is a design handoff, not an implementation report. + +## Repository and tracking + +- Repository: `anomalyco/opencode` +- Target branch: `v2` +- Latest ref inspected during discussion: `1c67004999` on 2026-07-10 +- Primary tracker: https://github.com/anomalyco/opencode/issues/36196 +- Related architecture tracker: https://github.com/anomalyco/opencode/issues/35364 +- Source Slack thread: https://slack.com/archives/C0BE69AHCQP/p1783709543707769 +- No implementation worktree, branch, commit, or PR remains from this session. A local implementation was started because of a misunderstanding, immediately stopped, and deleted. Nothing was pushed. + +--- + +# Executive decision summary + +1. **Tool remains the canonical capability.** CodeMode consumes Tools; ordinary Tool authors do not need to understand CodeMode. +2. **Tools enter CodeMode by default.** `codemode: false` opts a Tool into the provider's top-level/native Tool list. +3. **Tool definitions are flat objects.** Promise already has this public shape; Effect should support it too without requiring `Tool.make(...)` in ordinary usage. +4. **`namespace`, `codemode`, and `pinned` are first-class Tool-definition fields.** +5. **Namespaces are optional, explicitly describable, implicitly created, nested through dotted strings, and may themselves be callable Tools.** +6. **The provider-level `execute` Tool stays stable.** Dynamic Tool catalog text moves into one agent-filtered Instruction source. +7. **CodeMode owns catalog semantics and rendering.** OpenCode owns permission filtering, budget selection, and durable Instruction lifecycle. +8. **Small catalogs inline all signatures; large catalogs show compact namespace guidance plus pinned signatures, using a token budget rather than Tool count.** +9. **Catalog changes produce semantic Instruction deltas.** Use a full replacement when smaller or when compact/full mode changes. +10. **Search remains exactly as currently implemented for now.** Async Tool calls and `Promise.all` also remain. +11. **Persist one whole deterministic, content-addressed catalog snapshot initially.** Do not prematurely build per-Tool storage. +12. **No Tool watchers or execution wakeups.** Recompute from current visible Tools whenever an LLM request is already being built. + +--- + +# Proposed public API shapes + +The exact exported type names may change during implementation, but these call-site shapes were agreed. + +## Canonical Tool definition + +```ts +type ToolDefinition = { + name: string + + /** Dotted CodeMode path prefix, e.g. "slack.admin". */ + namespace?: string + + /** Defaults to true. False exposes the Tool directly to the provider. */ + codemode?: boolean + + /** Defaults to false. Valid only when codemode !== false. */ + pinned?: boolean + + description: string + input: Schema.Codec + output: Schema.Codec + execute: (input: Input, context: ToolContext) => Promise | Effect.Effect +} +``` + +Dynamic adapter-backed Tools retain the corresponding JSON Schema shape: + +```ts +type DynamicToolDefinition = { + name: string + namespace?: string + codemode?: boolean + pinned?: boolean + description: string + jsonSchema: JsonSchema + outputSchema?: JsonSchema + execute: (input: unknown, context: ToolContext) => Promise | Effect.Effect +} +``` + +The Promise and Effect APIs may still differ at the async boundary, but they should share the same flat declaration model. + +## Promise plugin example + +```ts +import { Plugin } from "@opencode-ai/plugin/v2" +import { Schema } from "effect" + +export default Plugin.define({ + id: "slack-tools", + + setup: async (ctx) => { + await ctx.tool.transform((draft) => { + draft.namespace.add({ + name: "slack", + description: "Read and act in Slack", + }) + + draft.add({ + name: "send", + namespace: "slack", + pinned: true, + description: "Send a Slack message", + input: Schema.Struct({ + channel: Schema.String, + text: Schema.String, + }), + output: Schema.Struct({ sent: Schema.Boolean }), + execute: async ({ channel, text }) => sendSlackMessage(channel, text), + }) + + draft.add({ + name: "edit", + codemode: false, + description: "Edit a file", + input: EditInput, + output: EditOutput, + execute: edit, + }) + }) + }, +}) +``` + +## Effect plugin example + +The desired Effect shape is equally flat: + +```ts +import { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema } from "effect" + +export default Plugin.define({ + id: "slack-tools", + + effect: Effect.fn(function* (ctx) { + yield* ctx.tool.transform((draft) => { + draft.namespace.add({ + name: "slack", + description: "Read and act in Slack", + }) + + draft.add({ + name: "send", + namespace: "slack", + pinned: true, + description: "Send a Slack message", + input: Schema.Struct({ + channel: Schema.String, + text: Schema.String, + }), + output: Schema.Struct({ sent: Schema.Boolean }), + execute: ({ channel, text }) => sendSlackMessage(channel, text), + }) + }) + }), +}) +``` + +The group explicitly wants normal Effect usage not to require this nested construction: + +```ts +draft.add("send", Tool.make({ ... }), options) +``` + +An opaque/private constructor may still exist internally; the public Draft interaction should be flat. + +## Namespace declaration + +```ts +type NamespaceDefinition = { + name: string + description: string +} + +interface ToolDraft { + add(tool: ToolDefinition | DynamicToolDefinition): void + + namespace: { + add(namespace: NamespaceDefinition): void + } +} +``` + +Explicit namespace registration is optional: + +```ts +draft.namespace.add({ + name: "slack", + description: "Read and act in Slack", +}) +``` + +A Tool implicitly creates its namespace when metadata was not registered: + +```ts +draft.add({ + name: "send", + namespace: "slack", + // ... +}) +``` + +`description` was chosen over `summary` to match existing Tool, Skill, agent, and model-facing vocabulary. + +## MCP registration example + +MCP Tools are the primary immediate consumer: + +```ts +await ctx.tool.transform((draft) => { + for (const server of servers) { + const configuredDescription = namespaceDescriptions[server.name] + + if (configuredDescription) { + draft.namespace.add({ + name: server.name, + description: configuredDescription, + }) + } + + for (const remote of server.tools) { + draft.add({ + name: remote.name, + namespace: server.name, + description: remote.description ?? "", + jsonSchema: remote.inputSchema, + outputSchema: remote.outputSchema, + execute: (input, context) => + callMcpTool({ + server: server.name, + tool: remote.name, + input, + context, + }), + }) + } + } +}) +``` + +MCP facts established during the grill: + +- MCP Tools do not carry a protocol-level namespace field. +- OpenCode already carries each Tool's configured server name. +- The configured server name becomes the namespace. +- MCP servers may provide an initialization `instructions` string, but not the concise namespace description this design wants. +- Server `instructions` remain their existing Instruction source. +- Individual MCP Tools and JSON Schema properties can carry descriptions; these become catalog metadata. + +--- + +# Namespace semantics + +## Dotted nested paths + +Use validated dotted namespace strings: + +```ts +{ + name: "invite", + namespace: "slack.admin.users", +} +``` + +Model-visible path: + +```ts +tools.slack.admin.users.invite(...) +``` + +Arrays were considered and rejected for now: + +```ts +namespace: ["slack", "admin", "users"] // not chosen +``` + +Validation should reject empty/invalid segments and must not silently normalize names. + +## Callable namespaces + +One path may be both a Tool and a namespace: + +```ts +tools.slack.admin(...) +tools.slack.admin.invite(...) +``` + +This is valid JavaScript: functions can own properties. CodeMode currently treats a node as either a Tool leaf or a namespace branch, so supporting callable namespace nodes is a focused required CodeMode change. + +Conceptual internal representation: + +```ts +type ToolNode = { + tool?: Tool + children: Map +} +``` + +The public host representation used by `@opencode-ai/codemode` was not settled. Do not assume the temporary API name `Tool.withChildren`; implement the smallest representation consistent with the canonical flat Tool registry. + +## Scoped collisions + +Exact same-path registrations keep current scoped semantics: + +```text +latest active registration wins +plugin disposal reveals the previous registration +``` + +Apply the same rule to explicit namespace descriptions. An implicit namespace must never override explicit metadata. + +The group initially considered rejecting Tool/namespace leaf-branch conflicts, then explicitly reversed that decision after recognizing callable objects. Parent and child paths must coexist. + +--- + +# `codemode` and native Tool projection + +## Defaults + +```ts +draft.add(slackSend) // codemode defaults true + +draft.add({ + ...edit, + codemode: false, +}) +``` + +- Ordinary visible Tools enter CodeMode by default. +- `codemode: false` opts into the provider's native Tool field. +- The `execute` Tool itself uses `codemode: false`; therefore it does not recursively appear inside CodeMode. +- If zero CodeMode Tools are visible to the selected agent, omit `execute`, omit CodeMode Instructions, and omit empty namespaces. +- Avoid exposing one capability both natively and through CodeMode. + +The group considered names such as `exposure`, `surface`, `presentation`, `deferred`, and `group`. Final decisions: + +- Never use `exposure`. +- Replace current `deferred` semantics with `codemode`. +- Spell `codemode` all lowercase. +- Replace `group` with `namespace`. + +## Deferred native-name question + +The exact provider Tool name for this shape was explicitly deferred: + +```ts +{ + name: "invite", + namespace: "slack.admin", + codemode: false, +} +``` + +`slack_admin_invite` was recommended because it preserves current flattening, but the group requested implementation ignore this question for now. + +--- + +# Pinned Tools + +`pinned` is first-class Tool metadata: + +```ts +draft.add({ + name: "read", + namespace: "filesystem", + pinned: true, + description: "Read a file", + input: ReadInput, + output: ReadOutput, + execute: read, +}) +``` + +Meaning: + +- Pinned CodeMode Tools receive complete model-facing signatures in dynamic Instructions even when the rest of the catalog is compact. +- Pinning affects model guidance only, not permission, execution, or provider-level placement. +- Permission filtering happens before pinned signatures are rendered. +- `pinned` only applies to CodeMode Tools. +- Reject this meaningless combination: + +```ts +{ codemode: false, pinned: true } +``` + +Native Tools are already fully described in the provider Tool field. + +--- + +# LLM request shape + +## Current V2 behavior + +Today V2 roughly sends: + +```ts +LLM.request({ + system: [ + SystemPart.make(agentSystemOrProviderPrompt), + SystemPart.make(initialInstructions), + ], + + messages: sessionHistoryWithInstructionUpdates, + + tools: [ + directBuiltIns, + { + name: "execute", + description: CodeMode.make({ tools }).instructions(), + inputSchema: { + type: "object", + properties: { code: { type: "string" } }, + required: ["code"], + }, + }, + ], +}) +``` + +The dynamic CodeMode catalog currently lives inside `execute.description`, which changes the provider Tool definition whenever the catalog changes. + +## Agreed target + +Keep `execute` stable: + +```ts +{ + name: "execute", + description: ` +Run confined JavaScript through { code }. +Call capabilities through tools. +Use the existing CodeMode search API to discover exact signatures. +Await important calls; use Promise.all for independent calls. +`, + inputSchema: { + type: "object", + properties: { code: { type: "string" } }, + required: ["code"], + }, +} +``` + +The exact invariant prose can continue to come from CodeMode. It must not include changing Tool/namespace catalog data. + +Dynamic catalog data becomes a durable Instruction source. + +--- + +# CodeMode Catalog API and ownership + +The exact public names below were illustrative, but the ownership boundary and data flow were agreed. + +```ts +const visible = toolRegistry.materialize({ + agent, + permissions, +}) + +const catalog = CodeMode.Catalog.build({ + tools: visible.codemodeTools, + namespaces: visible.namespaces, + tokenBudget: 2_000, +}) +``` + +CodeMode should return a deterministic structured snapshot and pure renderers: + +```ts +type CatalogSnapshot = { + mode: "full" | "compact" + namespaces: ReadonlyArray<{ + name: string + description?: string + toolCount: number + }> + tools: ReadonlyArray<{ + path: string + description: string + signature: string + pinned: boolean + }> +} + +CodeMode.Catalog.diff(previous, current) +// { +// added, +// removed, +// changed, +// } + +CodeMode.Catalog.renderInitial(current) +CodeMode.Catalog.renderChanged(previous, current) +``` + +## Final ownership boundary + +CodeMode owns: + +- canonical Tool descriptors +- exact generated signatures +- namespace and pinned semantics +- search indexing +- full/compact planning +- catalog structural diffing +- canonical pure initial/update rendering + +OpenCode owns: + +- selected-agent and permission filtering +- supplying the configured/default token budget +- composing the CodeMode Instruction source +- durable value/hash persistence +- chronology and request delivery + +This final boundary supersedes an earlier suggestion that OpenCode own catalog rendering. + +--- + +# Dynamic Instruction source + +Illustrative integration: + +```ts +Instructions.make({ + key: "core/codemode", + codec: CodeMode.Catalog.Snapshot, + read: buildCurrentVisibleCodeModeCatalog(), + render: { + initial: CodeMode.Catalog.renderInitial, + changed: CodeMode.Catalog.renderChanged, + }, +}) +``` + +The key name `core/codemode` was illustrative rather than separately bikeshedded. + +## Initial rendering + +- If all visible signatures fit within the token budget, inline all full signatures. +- Otherwise render compact namespace guidance plus every pinned signature. +- Always determine size by estimated tokens, not Tool count. +- Initial recommended/default budget: 2,000 tokens. + +Example compact shape: + +```md +CodeMode catalog metadata follows. It describes callable APIs. + +Namespaces: +- slack (23) — Read and act in Slack +- github.actions (14) — Manage GitHub Actions + +Pinned: +- tools.filesystem.read(input: ...): Promise<...> +``` + +The exact fallback when a large namespace has no explicit description was explicitly deferred. A capped sorted Tool-name list plus `… +N` was recommended but not locked. + +## Mid-conversation changes + +Render semantic deltas: + +```md +CodeMode catalog changed: + +Added: +- tools.slack.canvas.create(...) + +Removed: +- tools.slack.legacyPost + +Changed: +- tools.github.issue.update(input: NewInput): Promise +``` + +Use a full replacement when: + +- the full rendering is smaller than the semantic delta, or +- the catalog crosses between full and compact rendering modes. + +Tool permission changes and agent changes naturally appear as additions/removals because filtering happens before snapshot construction. + +## No new lifecycle + +Do not add Tool watchers, Session wakeups, timers, or a special "safe boundary" subsystem. + +Immediately before each LLM request, when the runner is already doing ordinary request assembly: + +```ts +const visible = toolRegistry.visibleFor(selectedAgent) +const catalog = CodeMode.Catalog.build(visible) +``` + +- If the Session is idle, nothing happens. +- An in-flight provider request remains unchanged. +- The next request sees the current catalog. +- If a removed Tool is somehow called and cannot resolve, an ordinary unknown-Tool failure is acceptable. + +--- + +# Durable storage + +The existing Instructions algebra already supplies previous and current typed values to pure renderers: + +```ts +changed: (previous, current) => + CodeMode.Catalog.renderChanged(previous, current) +``` + +Expected flow: + +1. Build deterministic model-facing catalog snapshot. +2. Canonically encode and hash it. +3. Store the snapshot once in global `instruction_blob`. +4. Persist only `{ "core/codemode": hash }` in the durable Instruction delta. +5. At request assembly, hydrate old/new values and render the semantic change. + +Store only model-facing descriptor data: + +- paths +- descriptions +- signatures +- namespace metadata +- pinned/full/compact planning state as needed for deterministic replay + +Do not store: + +- executors or closures +- raw OpenAPI specs +- credentials +- permission internals + +Identical snapshots deduplicate globally across Sessions. Whole-snapshot replacement is accepted for the first version. If measured catalog churn later causes meaningful storage growth, consider per-Tool blobs plus a manifest; do not build that now. + +--- + +# Search and language semantics + +Several simplifications were explored and then explicitly deferred. + +## Final search decision + +Leave current search unchanged: + +```ts +await tools.$codemode.search({ + query: "upload Slack file", + namespace: "slack", + limit: 10, + offset: 0, +}) +``` + +Current result remains the paginated object containing exact paths, descriptions, and signatures. + +Ideas considered but not selected now: + +```ts +search("upload Slack file") +search("ns:slack upload file") +``` + +A synchronous global `search` and plain-array result were discussed, briefly preferred, and then explicitly reverted in favor of leaving search alone. + +## Async Tool calls + +Keep normal JavaScript async semantics: + +```ts +const user = await tools.slack.user({ id }) +const messages = await tools.slack.messages({ user: user.id }) +``` + +Keep explicit concurrency: + +```ts +const [issue, pull] = await Promise.all([ + tools.github.issue({ number: 1 }), + tools.github.pull({ number: 2 }), +]) +``` + +Do not make Tool calls magically synchronous. Do not change the DSL to Haskell. Do not introduce Haxl-style query/command classification or batching without a demonstrated datasource need. + +--- + +# Agent and permission behavior + +Catalog construction must know the selected agent and its effective permissions. + +```ts +const visible = toolRegistry.materialize({ + agent: selectedAgent, + permissions: effectivePermissions, +}) +``` + +Requirements: + +- Denied Tools are omitted entirely from native definitions, CodeMode catalog, pinned signatures, search, and namespace counts. +- Denied subagents are omitted from the subagent Tool's available choices. +- Do not advertise a Tool and rely on a later denial for ordinary catalog visibility. +- Runtime authorization remains inside the canonical Tool/permission path. Catalog filtering is visibility, not the final authorization boundary. + +--- + +# MCP metadata trust decision + +Clarification: + +- MCP servers do not provide a concise server description. +- MCP Tools may provide `description`. +- Tool input/output JSON Schema fields may provide `description`. +- Server initialization may provide `instructions`, which is separate. + +The catalog may therefore contain configured third-party metadata such as: + +```ts +tools.slack.send_message(input: { + /** Channel to send into */ + channel: string +}): Promise // Send a Slack message +``` + +The group explicitly decided to accept this as configured extension content and not add special security/sanitization machinery. A single stable framing sentence is fine; the overall token budget is sufficient bounding for now. + +--- + +# Complete gang-grill decision log + +## Q1 — Canonical Tool or separate CodeMode registry? + +**Question:** Should Tools be canonical and CodeMode project them, or should CodeMode own a second registry? + +**Answer:** Canonical Tool registry/type. Tool authors need not know CodeMode exists; CodeMode consumes canonical Tools downstream. Do not duplicate registration, permission, or execution paths. + +## Q2 — Native versus CodeMode routing API? + +**Question:** Should each Tool be annotated with placement or should a separate runtime policy decide? + +**Answer:** Tools default into CodeMode. Add an opt-out boolean because some built-ins/custom edit Tools must remain provider-level. After rejecting several names, the group chose lowercase `codemode: false`. + +Superseded names: `exposure`, `surface`, `presentation`, `route`, `deferred`. + +## Q3 — What stays in `execute.description`? + +**Question:** Should dynamic namespaces/signatures remain in the native Tool description? + +**Answer:** No. Keep only invariant language/runtime/search/safety guidance in the stable native description. Move changing namespace summaries, counts, and signatures into Instructions. + +## Q4 — Rename `execute`? + +**Question:** Consider `run`, `exec`, or another name to distance it from "executor." + +**Answer:** Keep `execute`. + +## Q5 — Simplify search syntax? + +**Question:** Replace `tools.$codemode.search(...)` with a global/string-based `search(...)`? + +**Answer:** Final answer is no change for now. Keep current search API. Earlier preferences for synchronous/string search were superseded. + +## Q6 — Grouping concept? + +**Question:** `group`, `category`, `module`, or `namespace`? + +**Answer:** `namespace`. It creates the actual callable path and is a first-class Tool concept. Remove current `group` wording. + +## Q7 — Namespace registration spelling? + +**Question:** `draft.namespace.add(...)` or `draft.addNamespace(...)`? + +**Answer:** `draft.namespace.add(...)`. + +## Q8 — Nested namespace representation? + +**Question:** Dotted string or string array? + +**Answer:** Validated dotted string. Arrays add ceremony without a demonstrated need. + +## Q9 — Pinned Tool metadata? + +**Question:** Should `pinned` be a first-class Tool field? + +**Answer:** Yes. Default false. CodeMode-only. Pinned Tools retain full signatures in compact catalogs. + +## Q10 — Namespace fallback without description? + +**Question:** What is shown for implicit namespaces with no description? + +**Answer:** Small catalogs inline all signatures. For large catalogs, a capped Tool-name fallback was recommended but exact rendering was later explicitly deferred. + +## Q11 — Tool count or token budget? + +**Question:** Decide full/compact mode using Tool count or estimated tokens? + +**Answer:** Token budget. Initial recommendation/default: 2,000 tokens. + +## Q12 — Delta or full replacement? + +**Question:** How should catalog changes appear mid-conversation? + +**Answer:** Semantic added/removed/signature-changed delta. Full replacement when smaller or when compact/full mode changes. + +## Q13 — Diff/render ownership? + +**Question:** CodeMode or OpenCode? + +**Answer:** Final revised answer: CodeMode owns structured catalog semantics, diffing, planning, and canonical pure renderers. OpenCode filters visible Tools and transports snapshots through Instructions. + +## Q14 — Synchronous search? + +**Question:** Should local catalog search be synchronous? + +**Answer:** Initially yes, but this was later explicitly superseded. Search stays current/async for now. + +## Q15 — `summary` or `description`? + +**Question:** Namespace metadata field name? + +**Answer:** `description`. + +## Q16 — Exact-path duplicate registration? + +**Question:** Error or last registration wins? + +**Answer:** Preserve current scoped latest-active-wins semantics; disposal reveals previous registration. + +## Q17 — Tool path also a namespace? + +**Question:** Reject leaf/branch conflict or permit both? + +**Answer:** Permit both. JavaScript callable objects support properties. This reversed the initial rejection recommendation. + +## Q18 — MCP description trust? + +**Question:** Do MCP descriptions need special sanitization before system Instructions? + +**Answer:** No extra machinery. Treat configured MCP Tool/schema descriptions as accepted extension metadata. Server `instructions` remain separate. + +## Q19 — Search result plain array? + +**Question:** Simplify search to a top-10 array? + +**Answer:** No change for now. Keep current paginated search object. + +## Q20 — Tool-change lifecycle? + +**Question:** Watch Tools or wake Sessions when the catalog changes? + +**Answer:** No. Recompute when already assembling each LLM request. Missing calls may fail normally. + +## Q21 — Empty CodeMode behavior? + +**Question:** What if no visible CodeMode Tools exist? + +**Answer:** Omit `execute`, CodeMode Instructions, and empty namespaces. + +## Q22 — Namespaced native Tool flattening? + +**Question:** How should `{ namespace: "slack.admin", name: "invite", codemode: false }` lower to a provider Tool name? + +**Answer:** Explicitly deferred/ignored. `slack_admin_invite` was recommended but not locked. + +## Tangent — Haxl/Haskell DSL? + +**Question:** Should implicit concurrency remove `await` or should CodeMode become Haskell/Haxl-like? + +**Answer:** No. Keep JavaScript, `await`, and `Promise.all`. Haxl-style batching requires a real query/command distinction and demonstrated need. + +--- + +# Explicitly deferred / ignored questions + +The implementation handoff should not reopen these before useful progress: + +1. Exact native/provider name for namespaced `codemode: false` Tools. +2. Exact compact fallback text when a large namespace has no explicit description. +3. Public OpenCode configuration path/name for the catalog token budget. +4. Simplified search syntax/result shape. +5. Per-Tool content-addressed blob storage. +6. Haxl-style query batching or synchronous-looking Tool calls. + +Use the simplest compatible behavior, preserve current behavior where possible, and file narrow follow-ups if implementation genuinely forces a choice. + +--- + +# Suggested implementation sequence + +1. **Public Tool declarations** + - Add flat Effect Draft declarations matching Promise ergonomics. + - Add `namespace`, `codemode`, and `pinned` fields and validation. +2. **Namespace registry** + - Add scoped `draft.namespace.add({ name, description })`. + - Add implicit namespace creation from Tool declarations. + - Add dotted-path validation. +3. **Callable namespace support** + - Change CodeMode's tool tree/catalog/runtime representation so a node may hold both a Tool and children. + - Do not settle an unnecessary host helper API before seeing the canonical registry adapter. +4. **Structured CodeMode catalog** + - Separate invariant execute guidance from dynamic catalog data. + - Preserve current search behavior. + - Add deterministic snapshot, planning, diff, and pure render APIs. +5. **Tool materialization** + - Partition selected-agent-visible Tools into native (`codemode: false`) and CodeMode sets. + - Validate `pinned` combinations. + - Omit empty CodeMode entirely. +6. **Instruction integration** + - Compose the CodeMode source explicitly in `SessionRunner.loadInstructions`. + - Persist deterministic descriptor-only snapshots through existing Instruction CAS. + - Render initial/full/compact and semantic updates. +7. **MCP adapter** + - Use server names as namespaces. + - Keep server instructions in existing guidance source. +8. **Tests and docs** + - Permission omission and agent switches. + - Exact-path scoped overrides. + - Callable parent/child paths. + - Pinned validation. + - Token-budget transitions. + - Semantic delta versus full replacement. + - Durable replay and blob deduplication. + - Empty CodeMode behavior. + - Update `packages/codemode/README.md`, `packages/codemode/codemode.md`, and relevant architecture notes. + +--- + +# Verification and publication + +- Create a dedicated branch/worktree from current `origin/v2`. +- Do not edit V1 `packages/opencode`. +- Run package tests from package directories, never repository root. +- Run `bun typecheck` from every changed package. +- Preserve one explicit `llm.stream(request)` per Physical Attempt. +- Run simplification review before publication. +- Push a dedicated branch and open a draft PR assigned to the requesting owner when requested. + +## Suggested skills + +- `opencode` +- `tdd` +- `codebase-design` +- `simplify` From 86ec7e706317fc3fd11f0284a15b29a7e9e691b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 16:13:18 -0500 Subject: [PATCH 2/4] test(core): mark promise plugin hello tool as native codemode now defaults true, so native-facing promise tests must opt out. --- packages/core/test/plugin/promise.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 433341d39894..57d15fb5ed19 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", + codemode: false, description: "Hello", input: Schema.Struct({ name: Schema.String }), output: Schema.String, From 4a4cd4eebaa00460c35f3e331cc82b1519b68602 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 16:25:30 -0500 Subject: [PATCH 3/4] fix(core): unbreak promise tool registration under codemode default Use options.codemode for Promise plugin tests, drop local-only design handoff from the branch, and remove packages/codemode/codemode.md. --- packages/codemode/AGENTS.md | 2 +- packages/codemode/codemode.md | 164 ---- packages/core/test/plugin/promise.test.ts | 2 +- specs/v2/codemode.md | 942 ---------------------- 4 files changed, 2 insertions(+), 1108 deletions(-) delete mode 100644 packages/codemode/codemode.md delete mode 100644 specs/v2/codemode.md 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 4b74cd05bdca..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, or deadlock it when queued work needs tool-call permits the leftovers occupy. -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. At most eight tool calls execute concurrently. - -The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no -defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call -concurrency and 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 tools through `Tools.Service`. Tools - default into CodeMode (`codemode` defaults true); `codemode: false` keeps a tool on the provider's native tool list. -- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes native tools - normally. -- When visible CodeMode tools exist, Core reserves and materializes one `execute` tool. Grouped CodeMode 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 CodeMode tools while CodeMode is enabled. Existing output -schemas are preserved in generated signatures. Native Core tools remain native 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/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 57d15fb5ed19..afe72f6b59f7 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -135,7 +135,7 @@ describe("fromPromise", () => { await ctx.tool.transform((tools) => { tools.add({ name: "hello", - codemode: false, + options: { codemode: false }, description: "Hello", input: Schema.Struct({ name: Schema.String }), output: Schema.String, diff --git a/specs/v2/codemode.md b/specs/v2/codemode.md deleted file mode 100644 index 4eff8f2f219b..000000000000 --- a/specs/v2/codemode.md +++ /dev/null @@ -1,942 +0,0 @@ -# Detailed Handoff: OpenCode V2 CodeMode Tool Catalog - -## Purpose - -This document captures the complete gang-grill outcome for redesigning V2 Tool registration, CodeMode catalog presentation, namespace semantics, and dynamic Instructions. It is intended to let a fresh implementation agent proceed without access to the original Slack conversation. - -This is a design handoff, not an implementation report. - -## Repository and tracking - -- Repository: `anomalyco/opencode` -- Target branch: `v2` -- Latest ref inspected during discussion: `1c67004999` on 2026-07-10 -- Primary tracker: https://github.com/anomalyco/opencode/issues/36196 -- Related architecture tracker: https://github.com/anomalyco/opencode/issues/35364 -- Source Slack thread: https://slack.com/archives/C0BE69AHCQP/p1783709543707769 -- No implementation worktree, branch, commit, or PR remains from this session. A local implementation was started because of a misunderstanding, immediately stopped, and deleted. Nothing was pushed. - ---- - -# Executive decision summary - -1. **Tool remains the canonical capability.** CodeMode consumes Tools; ordinary Tool authors do not need to understand CodeMode. -2. **Tools enter CodeMode by default.** `codemode: false` opts a Tool into the provider's top-level/native Tool list. -3. **Tool definitions are flat objects.** Promise already has this public shape; Effect should support it too without requiring `Tool.make(...)` in ordinary usage. -4. **`namespace`, `codemode`, and `pinned` are first-class Tool-definition fields.** -5. **Namespaces are optional, explicitly describable, implicitly created, nested through dotted strings, and may themselves be callable Tools.** -6. **The provider-level `execute` Tool stays stable.** Dynamic Tool catalog text moves into one agent-filtered Instruction source. -7. **CodeMode owns catalog semantics and rendering.** OpenCode owns permission filtering, budget selection, and durable Instruction lifecycle. -8. **Small catalogs inline all signatures; large catalogs show compact namespace guidance plus pinned signatures, using a token budget rather than Tool count.** -9. **Catalog changes produce semantic Instruction deltas.** Use a full replacement when smaller or when compact/full mode changes. -10. **Search remains exactly as currently implemented for now.** Async Tool calls and `Promise.all` also remain. -11. **Persist one whole deterministic, content-addressed catalog snapshot initially.** Do not prematurely build per-Tool storage. -12. **No Tool watchers or execution wakeups.** Recompute from current visible Tools whenever an LLM request is already being built. - ---- - -# Proposed public API shapes - -The exact exported type names may change during implementation, but these call-site shapes were agreed. - -## Canonical Tool definition - -```ts -type ToolDefinition = { - name: string - - /** Dotted CodeMode path prefix, e.g. "slack.admin". */ - namespace?: string - - /** Defaults to true. False exposes the Tool directly to the provider. */ - codemode?: boolean - - /** Defaults to false. Valid only when codemode !== false. */ - pinned?: boolean - - description: string - input: Schema.Codec - output: Schema.Codec - execute: (input: Input, context: ToolContext) => Promise | Effect.Effect -} -``` - -Dynamic adapter-backed Tools retain the corresponding JSON Schema shape: - -```ts -type DynamicToolDefinition = { - name: string - namespace?: string - codemode?: boolean - pinned?: boolean - description: string - jsonSchema: JsonSchema - outputSchema?: JsonSchema - execute: (input: unknown, context: ToolContext) => Promise | Effect.Effect -} -``` - -The Promise and Effect APIs may still differ at the async boundary, but they should share the same flat declaration model. - -## Promise plugin example - -```ts -import { Plugin } from "@opencode-ai/plugin/v2" -import { Schema } from "effect" - -export default Plugin.define({ - id: "slack-tools", - - setup: async (ctx) => { - await ctx.tool.transform((draft) => { - draft.namespace.add({ - name: "slack", - description: "Read and act in Slack", - }) - - draft.add({ - name: "send", - namespace: "slack", - pinned: true, - description: "Send a Slack message", - input: Schema.Struct({ - channel: Schema.String, - text: Schema.String, - }), - output: Schema.Struct({ sent: Schema.Boolean }), - execute: async ({ channel, text }) => sendSlackMessage(channel, text), - }) - - draft.add({ - name: "edit", - codemode: false, - description: "Edit a file", - input: EditInput, - output: EditOutput, - execute: edit, - }) - }) - }, -}) -``` - -## Effect plugin example - -The desired Effect shape is equally flat: - -```ts -import { Plugin } from "@opencode-ai/plugin/v2/effect" -import { Effect, Schema } from "effect" - -export default Plugin.define({ - id: "slack-tools", - - effect: Effect.fn(function* (ctx) { - yield* ctx.tool.transform((draft) => { - draft.namespace.add({ - name: "slack", - description: "Read and act in Slack", - }) - - draft.add({ - name: "send", - namespace: "slack", - pinned: true, - description: "Send a Slack message", - input: Schema.Struct({ - channel: Schema.String, - text: Schema.String, - }), - output: Schema.Struct({ sent: Schema.Boolean }), - execute: ({ channel, text }) => sendSlackMessage(channel, text), - }) - }) - }), -}) -``` - -The group explicitly wants normal Effect usage not to require this nested construction: - -```ts -draft.add("send", Tool.make({ ... }), options) -``` - -An opaque/private constructor may still exist internally; the public Draft interaction should be flat. - -## Namespace declaration - -```ts -type NamespaceDefinition = { - name: string - description: string -} - -interface ToolDraft { - add(tool: ToolDefinition | DynamicToolDefinition): void - - namespace: { - add(namespace: NamespaceDefinition): void - } -} -``` - -Explicit namespace registration is optional: - -```ts -draft.namespace.add({ - name: "slack", - description: "Read and act in Slack", -}) -``` - -A Tool implicitly creates its namespace when metadata was not registered: - -```ts -draft.add({ - name: "send", - namespace: "slack", - // ... -}) -``` - -`description` was chosen over `summary` to match existing Tool, Skill, agent, and model-facing vocabulary. - -## MCP registration example - -MCP Tools are the primary immediate consumer: - -```ts -await ctx.tool.transform((draft) => { - for (const server of servers) { - const configuredDescription = namespaceDescriptions[server.name] - - if (configuredDescription) { - draft.namespace.add({ - name: server.name, - description: configuredDescription, - }) - } - - for (const remote of server.tools) { - draft.add({ - name: remote.name, - namespace: server.name, - description: remote.description ?? "", - jsonSchema: remote.inputSchema, - outputSchema: remote.outputSchema, - execute: (input, context) => - callMcpTool({ - server: server.name, - tool: remote.name, - input, - context, - }), - }) - } - } -}) -``` - -MCP facts established during the grill: - -- MCP Tools do not carry a protocol-level namespace field. -- OpenCode already carries each Tool's configured server name. -- The configured server name becomes the namespace. -- MCP servers may provide an initialization `instructions` string, but not the concise namespace description this design wants. -- Server `instructions` remain their existing Instruction source. -- Individual MCP Tools and JSON Schema properties can carry descriptions; these become catalog metadata. - ---- - -# Namespace semantics - -## Dotted nested paths - -Use validated dotted namespace strings: - -```ts -{ - name: "invite", - namespace: "slack.admin.users", -} -``` - -Model-visible path: - -```ts -tools.slack.admin.users.invite(...) -``` - -Arrays were considered and rejected for now: - -```ts -namespace: ["slack", "admin", "users"] // not chosen -``` - -Validation should reject empty/invalid segments and must not silently normalize names. - -## Callable namespaces - -One path may be both a Tool and a namespace: - -```ts -tools.slack.admin(...) -tools.slack.admin.invite(...) -``` - -This is valid JavaScript: functions can own properties. CodeMode currently treats a node as either a Tool leaf or a namespace branch, so supporting callable namespace nodes is a focused required CodeMode change. - -Conceptual internal representation: - -```ts -type ToolNode = { - tool?: Tool - children: Map -} -``` - -The public host representation used by `@opencode-ai/codemode` was not settled. Do not assume the temporary API name `Tool.withChildren`; implement the smallest representation consistent with the canonical flat Tool registry. - -## Scoped collisions - -Exact same-path registrations keep current scoped semantics: - -```text -latest active registration wins -plugin disposal reveals the previous registration -``` - -Apply the same rule to explicit namespace descriptions. An implicit namespace must never override explicit metadata. - -The group initially considered rejecting Tool/namespace leaf-branch conflicts, then explicitly reversed that decision after recognizing callable objects. Parent and child paths must coexist. - ---- - -# `codemode` and native Tool projection - -## Defaults - -```ts -draft.add(slackSend) // codemode defaults true - -draft.add({ - ...edit, - codemode: false, -}) -``` - -- Ordinary visible Tools enter CodeMode by default. -- `codemode: false` opts into the provider's native Tool field. -- The `execute` Tool itself uses `codemode: false`; therefore it does not recursively appear inside CodeMode. -- If zero CodeMode Tools are visible to the selected agent, omit `execute`, omit CodeMode Instructions, and omit empty namespaces. -- Avoid exposing one capability both natively and through CodeMode. - -The group considered names such as `exposure`, `surface`, `presentation`, `deferred`, and `group`. Final decisions: - -- Never use `exposure`. -- Replace current `deferred` semantics with `codemode`. -- Spell `codemode` all lowercase. -- Replace `group` with `namespace`. - -## Deferred native-name question - -The exact provider Tool name for this shape was explicitly deferred: - -```ts -{ - name: "invite", - namespace: "slack.admin", - codemode: false, -} -``` - -`slack_admin_invite` was recommended because it preserves current flattening, but the group requested implementation ignore this question for now. - ---- - -# Pinned Tools - -`pinned` is first-class Tool metadata: - -```ts -draft.add({ - name: "read", - namespace: "filesystem", - pinned: true, - description: "Read a file", - input: ReadInput, - output: ReadOutput, - execute: read, -}) -``` - -Meaning: - -- Pinned CodeMode Tools receive complete model-facing signatures in dynamic Instructions even when the rest of the catalog is compact. -- Pinning affects model guidance only, not permission, execution, or provider-level placement. -- Permission filtering happens before pinned signatures are rendered. -- `pinned` only applies to CodeMode Tools. -- Reject this meaningless combination: - -```ts -{ codemode: false, pinned: true } -``` - -Native Tools are already fully described in the provider Tool field. - ---- - -# LLM request shape - -## Current V2 behavior - -Today V2 roughly sends: - -```ts -LLM.request({ - system: [ - SystemPart.make(agentSystemOrProviderPrompt), - SystemPart.make(initialInstructions), - ], - - messages: sessionHistoryWithInstructionUpdates, - - tools: [ - directBuiltIns, - { - name: "execute", - description: CodeMode.make({ tools }).instructions(), - inputSchema: { - type: "object", - properties: { code: { type: "string" } }, - required: ["code"], - }, - }, - ], -}) -``` - -The dynamic CodeMode catalog currently lives inside `execute.description`, which changes the provider Tool definition whenever the catalog changes. - -## Agreed target - -Keep `execute` stable: - -```ts -{ - name: "execute", - description: ` -Run confined JavaScript through { code }. -Call capabilities through tools. -Use the existing CodeMode search API to discover exact signatures. -Await important calls; use Promise.all for independent calls. -`, - inputSchema: { - type: "object", - properties: { code: { type: "string" } }, - required: ["code"], - }, -} -``` - -The exact invariant prose can continue to come from CodeMode. It must not include changing Tool/namespace catalog data. - -Dynamic catalog data becomes a durable Instruction source. - ---- - -# CodeMode Catalog API and ownership - -The exact public names below were illustrative, but the ownership boundary and data flow were agreed. - -```ts -const visible = toolRegistry.materialize({ - agent, - permissions, -}) - -const catalog = CodeMode.Catalog.build({ - tools: visible.codemodeTools, - namespaces: visible.namespaces, - tokenBudget: 2_000, -}) -``` - -CodeMode should return a deterministic structured snapshot and pure renderers: - -```ts -type CatalogSnapshot = { - mode: "full" | "compact" - namespaces: ReadonlyArray<{ - name: string - description?: string - toolCount: number - }> - tools: ReadonlyArray<{ - path: string - description: string - signature: string - pinned: boolean - }> -} - -CodeMode.Catalog.diff(previous, current) -// { -// added, -// removed, -// changed, -// } - -CodeMode.Catalog.renderInitial(current) -CodeMode.Catalog.renderChanged(previous, current) -``` - -## Final ownership boundary - -CodeMode owns: - -- canonical Tool descriptors -- exact generated signatures -- namespace and pinned semantics -- search indexing -- full/compact planning -- catalog structural diffing -- canonical pure initial/update rendering - -OpenCode owns: - -- selected-agent and permission filtering -- supplying the configured/default token budget -- composing the CodeMode Instruction source -- durable value/hash persistence -- chronology and request delivery - -This final boundary supersedes an earlier suggestion that OpenCode own catalog rendering. - ---- - -# Dynamic Instruction source - -Illustrative integration: - -```ts -Instructions.make({ - key: "core/codemode", - codec: CodeMode.Catalog.Snapshot, - read: buildCurrentVisibleCodeModeCatalog(), - render: { - initial: CodeMode.Catalog.renderInitial, - changed: CodeMode.Catalog.renderChanged, - }, -}) -``` - -The key name `core/codemode` was illustrative rather than separately bikeshedded. - -## Initial rendering - -- If all visible signatures fit within the token budget, inline all full signatures. -- Otherwise render compact namespace guidance plus every pinned signature. -- Always determine size by estimated tokens, not Tool count. -- Initial recommended/default budget: 2,000 tokens. - -Example compact shape: - -```md -CodeMode catalog metadata follows. It describes callable APIs. - -Namespaces: -- slack (23) — Read and act in Slack -- github.actions (14) — Manage GitHub Actions - -Pinned: -- tools.filesystem.read(input: ...): Promise<...> -``` - -The exact fallback when a large namespace has no explicit description was explicitly deferred. A capped sorted Tool-name list plus `… +N` was recommended but not locked. - -## Mid-conversation changes - -Render semantic deltas: - -```md -CodeMode catalog changed: - -Added: -- tools.slack.canvas.create(...) - -Removed: -- tools.slack.legacyPost - -Changed: -- tools.github.issue.update(input: NewInput): Promise -``` - -Use a full replacement when: - -- the full rendering is smaller than the semantic delta, or -- the catalog crosses between full and compact rendering modes. - -Tool permission changes and agent changes naturally appear as additions/removals because filtering happens before snapshot construction. - -## No new lifecycle - -Do not add Tool watchers, Session wakeups, timers, or a special "safe boundary" subsystem. - -Immediately before each LLM request, when the runner is already doing ordinary request assembly: - -```ts -const visible = toolRegistry.visibleFor(selectedAgent) -const catalog = CodeMode.Catalog.build(visible) -``` - -- If the Session is idle, nothing happens. -- An in-flight provider request remains unchanged. -- The next request sees the current catalog. -- If a removed Tool is somehow called and cannot resolve, an ordinary unknown-Tool failure is acceptable. - ---- - -# Durable storage - -The existing Instructions algebra already supplies previous and current typed values to pure renderers: - -```ts -changed: (previous, current) => - CodeMode.Catalog.renderChanged(previous, current) -``` - -Expected flow: - -1. Build deterministic model-facing catalog snapshot. -2. Canonically encode and hash it. -3. Store the snapshot once in global `instruction_blob`. -4. Persist only `{ "core/codemode": hash }` in the durable Instruction delta. -5. At request assembly, hydrate old/new values and render the semantic change. - -Store only model-facing descriptor data: - -- paths -- descriptions -- signatures -- namespace metadata -- pinned/full/compact planning state as needed for deterministic replay - -Do not store: - -- executors or closures -- raw OpenAPI specs -- credentials -- permission internals - -Identical snapshots deduplicate globally across Sessions. Whole-snapshot replacement is accepted for the first version. If measured catalog churn later causes meaningful storage growth, consider per-Tool blobs plus a manifest; do not build that now. - ---- - -# Search and language semantics - -Several simplifications were explored and then explicitly deferred. - -## Final search decision - -Leave current search unchanged: - -```ts -await tools.$codemode.search({ - query: "upload Slack file", - namespace: "slack", - limit: 10, - offset: 0, -}) -``` - -Current result remains the paginated object containing exact paths, descriptions, and signatures. - -Ideas considered but not selected now: - -```ts -search("upload Slack file") -search("ns:slack upload file") -``` - -A synchronous global `search` and plain-array result were discussed, briefly preferred, and then explicitly reverted in favor of leaving search alone. - -## Async Tool calls - -Keep normal JavaScript async semantics: - -```ts -const user = await tools.slack.user({ id }) -const messages = await tools.slack.messages({ user: user.id }) -``` - -Keep explicit concurrency: - -```ts -const [issue, pull] = await Promise.all([ - tools.github.issue({ number: 1 }), - tools.github.pull({ number: 2 }), -]) -``` - -Do not make Tool calls magically synchronous. Do not change the DSL to Haskell. Do not introduce Haxl-style query/command classification or batching without a demonstrated datasource need. - ---- - -# Agent and permission behavior - -Catalog construction must know the selected agent and its effective permissions. - -```ts -const visible = toolRegistry.materialize({ - agent: selectedAgent, - permissions: effectivePermissions, -}) -``` - -Requirements: - -- Denied Tools are omitted entirely from native definitions, CodeMode catalog, pinned signatures, search, and namespace counts. -- Denied subagents are omitted from the subagent Tool's available choices. -- Do not advertise a Tool and rely on a later denial for ordinary catalog visibility. -- Runtime authorization remains inside the canonical Tool/permission path. Catalog filtering is visibility, not the final authorization boundary. - ---- - -# MCP metadata trust decision - -Clarification: - -- MCP servers do not provide a concise server description. -- MCP Tools may provide `description`. -- Tool input/output JSON Schema fields may provide `description`. -- Server initialization may provide `instructions`, which is separate. - -The catalog may therefore contain configured third-party metadata such as: - -```ts -tools.slack.send_message(input: { - /** Channel to send into */ - channel: string -}): Promise // Send a Slack message -``` - -The group explicitly decided to accept this as configured extension content and not add special security/sanitization machinery. A single stable framing sentence is fine; the overall token budget is sufficient bounding for now. - ---- - -# Complete gang-grill decision log - -## Q1 — Canonical Tool or separate CodeMode registry? - -**Question:** Should Tools be canonical and CodeMode project them, or should CodeMode own a second registry? - -**Answer:** Canonical Tool registry/type. Tool authors need not know CodeMode exists; CodeMode consumes canonical Tools downstream. Do not duplicate registration, permission, or execution paths. - -## Q2 — Native versus CodeMode routing API? - -**Question:** Should each Tool be annotated with placement or should a separate runtime policy decide? - -**Answer:** Tools default into CodeMode. Add an opt-out boolean because some built-ins/custom edit Tools must remain provider-level. After rejecting several names, the group chose lowercase `codemode: false`. - -Superseded names: `exposure`, `surface`, `presentation`, `route`, `deferred`. - -## Q3 — What stays in `execute.description`? - -**Question:** Should dynamic namespaces/signatures remain in the native Tool description? - -**Answer:** No. Keep only invariant language/runtime/search/safety guidance in the stable native description. Move changing namespace summaries, counts, and signatures into Instructions. - -## Q4 — Rename `execute`? - -**Question:** Consider `run`, `exec`, or another name to distance it from "executor." - -**Answer:** Keep `execute`. - -## Q5 — Simplify search syntax? - -**Question:** Replace `tools.$codemode.search(...)` with a global/string-based `search(...)`? - -**Answer:** Final answer is no change for now. Keep current search API. Earlier preferences for synchronous/string search were superseded. - -## Q6 — Grouping concept? - -**Question:** `group`, `category`, `module`, or `namespace`? - -**Answer:** `namespace`. It creates the actual callable path and is a first-class Tool concept. Remove current `group` wording. - -## Q7 — Namespace registration spelling? - -**Question:** `draft.namespace.add(...)` or `draft.addNamespace(...)`? - -**Answer:** `draft.namespace.add(...)`. - -## Q8 — Nested namespace representation? - -**Question:** Dotted string or string array? - -**Answer:** Validated dotted string. Arrays add ceremony without a demonstrated need. - -## Q9 — Pinned Tool metadata? - -**Question:** Should `pinned` be a first-class Tool field? - -**Answer:** Yes. Default false. CodeMode-only. Pinned Tools retain full signatures in compact catalogs. - -## Q10 — Namespace fallback without description? - -**Question:** What is shown for implicit namespaces with no description? - -**Answer:** Small catalogs inline all signatures. For large catalogs, a capped Tool-name fallback was recommended but exact rendering was later explicitly deferred. - -## Q11 — Tool count or token budget? - -**Question:** Decide full/compact mode using Tool count or estimated tokens? - -**Answer:** Token budget. Initial recommendation/default: 2,000 tokens. - -## Q12 — Delta or full replacement? - -**Question:** How should catalog changes appear mid-conversation? - -**Answer:** Semantic added/removed/signature-changed delta. Full replacement when smaller or when compact/full mode changes. - -## Q13 — Diff/render ownership? - -**Question:** CodeMode or OpenCode? - -**Answer:** Final revised answer: CodeMode owns structured catalog semantics, diffing, planning, and canonical pure renderers. OpenCode filters visible Tools and transports snapshots through Instructions. - -## Q14 — Synchronous search? - -**Question:** Should local catalog search be synchronous? - -**Answer:** Initially yes, but this was later explicitly superseded. Search stays current/async for now. - -## Q15 — `summary` or `description`? - -**Question:** Namespace metadata field name? - -**Answer:** `description`. - -## Q16 — Exact-path duplicate registration? - -**Question:** Error or last registration wins? - -**Answer:** Preserve current scoped latest-active-wins semantics; disposal reveals previous registration. - -## Q17 — Tool path also a namespace? - -**Question:** Reject leaf/branch conflict or permit both? - -**Answer:** Permit both. JavaScript callable objects support properties. This reversed the initial rejection recommendation. - -## Q18 — MCP description trust? - -**Question:** Do MCP descriptions need special sanitization before system Instructions? - -**Answer:** No extra machinery. Treat configured MCP Tool/schema descriptions as accepted extension metadata. Server `instructions` remain separate. - -## Q19 — Search result plain array? - -**Question:** Simplify search to a top-10 array? - -**Answer:** No change for now. Keep current paginated search object. - -## Q20 — Tool-change lifecycle? - -**Question:** Watch Tools or wake Sessions when the catalog changes? - -**Answer:** No. Recompute when already assembling each LLM request. Missing calls may fail normally. - -## Q21 — Empty CodeMode behavior? - -**Question:** What if no visible CodeMode Tools exist? - -**Answer:** Omit `execute`, CodeMode Instructions, and empty namespaces. - -## Q22 — Namespaced native Tool flattening? - -**Question:** How should `{ namespace: "slack.admin", name: "invite", codemode: false }` lower to a provider Tool name? - -**Answer:** Explicitly deferred/ignored. `slack_admin_invite` was recommended but not locked. - -## Tangent — Haxl/Haskell DSL? - -**Question:** Should implicit concurrency remove `await` or should CodeMode become Haskell/Haxl-like? - -**Answer:** No. Keep JavaScript, `await`, and `Promise.all`. Haxl-style batching requires a real query/command distinction and demonstrated need. - ---- - -# Explicitly deferred / ignored questions - -The implementation handoff should not reopen these before useful progress: - -1. Exact native/provider name for namespaced `codemode: false` Tools. -2. Exact compact fallback text when a large namespace has no explicit description. -3. Public OpenCode configuration path/name for the catalog token budget. -4. Simplified search syntax/result shape. -5. Per-Tool content-addressed blob storage. -6. Haxl-style query batching or synchronous-looking Tool calls. - -Use the simplest compatible behavior, preserve current behavior where possible, and file narrow follow-ups if implementation genuinely forces a choice. - ---- - -# Suggested implementation sequence - -1. **Public Tool declarations** - - Add flat Effect Draft declarations matching Promise ergonomics. - - Add `namespace`, `codemode`, and `pinned` fields and validation. -2. **Namespace registry** - - Add scoped `draft.namespace.add({ name, description })`. - - Add implicit namespace creation from Tool declarations. - - Add dotted-path validation. -3. **Callable namespace support** - - Change CodeMode's tool tree/catalog/runtime representation so a node may hold both a Tool and children. - - Do not settle an unnecessary host helper API before seeing the canonical registry adapter. -4. **Structured CodeMode catalog** - - Separate invariant execute guidance from dynamic catalog data. - - Preserve current search behavior. - - Add deterministic snapshot, planning, diff, and pure render APIs. -5. **Tool materialization** - - Partition selected-agent-visible Tools into native (`codemode: false`) and CodeMode sets. - - Validate `pinned` combinations. - - Omit empty CodeMode entirely. -6. **Instruction integration** - - Compose the CodeMode source explicitly in `SessionRunner.loadInstructions`. - - Persist deterministic descriptor-only snapshots through existing Instruction CAS. - - Render initial/full/compact and semantic updates. -7. **MCP adapter** - - Use server names as namespaces. - - Keep server instructions in existing guidance source. -8. **Tests and docs** - - Permission omission and agent switches. - - Exact-path scoped overrides. - - Callable parent/child paths. - - Pinned validation. - - Token-budget transitions. - - Semantic delta versus full replacement. - - Durable replay and blob deduplication. - - Empty CodeMode behavior. - - Update `packages/codemode/README.md`, `packages/codemode/codemode.md`, and relevant architecture notes. - ---- - -# Verification and publication - -- Create a dedicated branch/worktree from current `origin/v2`. -- Do not edit V1 `packages/opencode`. -- Run package tests from package directories, never repository root. -- Run `bun typecheck` from every changed package. -- Preserve one explicit `llm.stream(request)` per Physical Attempt. -- Run simplification review before publication. -- Push a dedicated branch and open a draft PR assigned to the requesting owner when requested. - -## Suggested skills - -- `opencode` -- `tdd` -- `codebase-design` -- `simplify` From a2d6c61bcdb6e4d1dea8086bf1a335f53fdf1bce Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 19:40:28 -0500 Subject: [PATCH 4/4] refactor(core): remove CODEMODE_ENABLED flag CodeMode is always on; drop the env kill-switch and always route MCP tools through codemode registration. --- packages/core/src/flag/flag.ts | 3 --- packages/core/src/mcp/guidance.ts | 18 +++++------------- packages/core/src/tool/mcp.ts | 4 ++-- packages/core/src/tool/registry.ts | 2 -- 4 files changed, 7 insertions(+), 20 deletions(-) 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/mcp.ts b/packages/core/src/tool/mcp.ts index 3f69ce51fc3c..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, codemode: Flag.CODEMODE_ENABLED }), + ([group, record]) => tools.register(record, { group }), { discard: true, }, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 3807d099bca1..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" @@ -174,7 +173,6 @@ const registryLayer = Layer.effect( for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - if (registration.codemode && !Flag.CODEMODE_ENABLED) continue if (whollyDisabled(permission(registration.tool, name), rules)) continue if (registration.codemode) codemode.set(name, registration) else direct.set(name, registration)