feat(opencode): gate execute tool behind code mode flag (#35185)
This commit is contained in:
parent
96d53c6716
commit
ed6dc879be
10 changed files with 174 additions and 15 deletions
|
|
@ -45,6 +45,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
|
|
|
|||
|
|
@ -1237,6 +1237,7 @@ const layer = Layer.effect(
|
|||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
|
|
@ -53,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
|
|
@ -91,6 +93,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||
modelID: ModelV2.ID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
permission: input.session.permission,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
|
|
@ -382,6 +385,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||
})
|
||||
}
|
||||
|
||||
if (flags.experimentalCodeMode) return tools
|
||||
|
||||
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
|
||||
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
|
||||
const execute = item.execute
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ import { BackgroundJob } from "@/background/job"
|
|||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@/mcp"
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
|
||||
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
|
||||
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
||||
|
|
@ -74,6 +77,7 @@ export interface Interface {
|
|||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +91,7 @@ const layer = Layer.effect(
|
|||
const agents = yield* Agent.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
|
|
@ -105,6 +110,8 @@ const layer = Layer.effect(
|
|||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
const agent = yield* Agent.Service
|
||||
const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined
|
||||
const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
|
|
@ -211,6 +218,7 @@ const layer = Layer.effect(
|
|||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -230,6 +238,7 @@ const layer = Layer.effect(
|
|||
tool.search,
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(tool.execute ? [tool.execute] : []),
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
],
|
||||
|
|
@ -263,6 +272,20 @@ const layer = Layer.effect(
|
|||
return ["Available agent types and the tools they have access to:", description].join("\n")
|
||||
})
|
||||
|
||||
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) {
|
||||
if (!codeMode) return
|
||||
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
|
||||
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
||||
if (Object.keys(tools).length === 0) return
|
||||
return codeMode.describeCatalog(
|
||||
tools,
|
||||
Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize),
|
||||
)
|
||||
})
|
||||
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
|
|
@ -277,8 +300,11 @@ const layer = Layer.effect(
|
|||
return true
|
||||
})
|
||||
|
||||
const codeModeDescription = filtered.some((tool) => tool.id === "execute") ? yield* describeCodeMode(input) : undefined
|
||||
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
|
||||
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
visible,
|
||||
Effect.fnUntraced(function* (tool: Tool.Def) {
|
||||
const output = {
|
||||
description: tool.description,
|
||||
|
|
@ -292,7 +318,11 @@ const layer = Layer.effect(
|
|||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
|
||||
description: [
|
||||
output.description,
|
||||
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
||||
tool.id === "execute" ? codeModeDescription : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
|
|
@ -412,6 +442,7 @@ export const node = LayerNode.make({
|
|||
Format.node,
|
||||
Truncate.node,
|
||||
RuntimeFlags.node,
|
||||
MCP.node,
|
||||
Database.node,
|
||||
Ripgrep.node,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ describe("code mode integration (real MCP server)", () => {
|
|||
expect(description).toContain("// Add two numbers and return the structured sum")
|
||||
expect(description).not.toContain("$codemode")
|
||||
expect(description).toContain("## Workflow")
|
||||
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
expect(description).toContain("Do not infer or normalize tool names")
|
||||
expect(description).toContain("bracket notation and quotes are part of the path")
|
||||
expect(description).not.toContain("total_count")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe("code mode execute", () => {
|
|||
expect(description).toContain("- zeta (1 tool)\n")
|
||||
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
|
||||
expect(description).toContain("tools.$codemode.search(")
|
||||
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
|
||||
expect(description).toContain("1. If the exact signature is not listed below, first search:")
|
||||
expect(description).toContain(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { MessageID, SessionID } from "@/session/schema"
|
|||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@/mcp"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
|
|
@ -55,6 +57,42 @@ const replacements = [
|
|||
] as const
|
||||
|
||||
const it = testEffect(LayerNode.compile(root, replacements))
|
||||
const withCodeMode = testEffect(
|
||||
LayerNode.compile(root, [
|
||||
[Config.node, configLayer],
|
||||
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () =>
|
||||
Effect.succeed({
|
||||
weather_current: {
|
||||
def: {
|
||||
name: "current",
|
||||
description: "current weather",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
} as MCPToolDef,
|
||||
client: {} as MCP.McpTool["client"],
|
||||
},
|
||||
}),
|
||||
clients: () => Effect.succeed({ weather: {} as any }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const withEmptyCodeMode = testEffect(
|
||||
LayerNode.compile(root, [
|
||||
[Config.node, configLayer],
|
||||
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({}),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -71,6 +109,47 @@ describe("tool.registry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.instance("does not expose execute unless code mode is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).not.toContain("execute")
|
||||
}),
|
||||
)
|
||||
|
||||
withCodeMode.instance("exposes execute when code mode is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const ids = yield* registry.ids()
|
||||
const tools = yield* registry.tools({
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
})
|
||||
const execute = tools.find((tool) => tool.id === "execute")
|
||||
|
||||
expect(ids).toContain("execute")
|
||||
expect(tools.map((tool) => tool.id)).toContain("execute")
|
||||
expect(execute?.description).toContain("tools.weather.current(input: { city: string })")
|
||||
}),
|
||||
)
|
||||
|
||||
withEmptyCodeMode.instance("does not expose execute when code mode has no visible tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const tools = yield* registry.tools({
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
})
|
||||
|
||||
expect(tools.map((tool) => tool.id)).not.toContain("execute")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue