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