feat(core): expose deferred tools through execute (#35361)
This commit is contained in:
@@ -321,6 +321,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
|
|||||||
@@ -1132,6 +1132,12 @@ Post-MVP (logged, not blocking an experimental flag):
|
|||||||
- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
|
- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
|
||||||
`session/system.ts:110-126`) still inject prose referencing server-native tool
|
`session/system.ts:110-126`) still inject prose referencing server-native tool
|
||||||
names that are no longer directly callable under code mode.
|
names that are no longer directly callable under code mode.
|
||||||
|
- [ ] Tool-tree path segments named `__proto__`, `constructor`, or `prototype` are included
|
||||||
|
in discovery but rejected by `ToolRuntime` resolution even when supplied as safe own
|
||||||
|
properties on null-prototype host records. Hosts should preserve registered names rather
|
||||||
|
than invent incompatible aliases. CodeMode should own a consistent policy: safely admit
|
||||||
|
these names as own tool-tree members, reject them before catalog generation with a clear
|
||||||
|
diagnostic, or define one canonical escaping contract.
|
||||||
|
|
||||||
### Backlog / loose ends (non-blocking, any order)
|
### Backlog / loose ends (non-blocking, any order)
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
"@ff-labs/fff-bun": "0.9.4",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export const Flag = {
|
|||||||
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
||||||
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
||||||
},
|
},
|
||||||
|
get CODEMODE_ENABLED() {
|
||||||
|
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
|
||||||
|
},
|
||||||
get OPENCODE_TUI_CONFIG() {
|
get OPENCODE_TUI_CONFIG() {
|
||||||
return process.env["OPENCODE_TUI_CONFIG"]
|
return process.env["OPENCODE_TUI_CONFIG"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as McpGuidance from "./guidance"
|
export * as McpGuidance from "./guidance"
|
||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
@@ -17,6 +18,11 @@ type Summary = typeof Summary.Type
|
|||||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||||
servers.flatMap((server) => [
|
servers.flatMap((server) => [
|
||||||
` <server name="${server.server}">`,
|
` <server name="${server.server}">`,
|
||||||
|
...(Flag.CODEMODE_ENABLED
|
||||||
|
? [
|
||||||
|
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||||
" </server>",
|
" </server>",
|
||||||
])
|
])
|
||||||
@@ -64,15 +70,17 @@ export const layer = Layer.effect(
|
|||||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||||
const agent = selection.info
|
const agent = selection.info
|
||||||
if (!agent) return SystemContext.empty
|
if (!agent) return SystemContext.empty
|
||||||
|
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||||
|
return SystemContext.empty
|
||||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
// Hide a server only when every tool it contributes is wholly denied for this agent.
|
// Instructions are useful only when this agent can reach at least one server tool.
|
||||||
const visible = instructions
|
const visible = instructions
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const owned = tools.filter((tool) => tool.server === item.server)
|
const owned = tools.filter((tool) => tool.server === item.server)
|
||||||
return (
|
return (
|
||||||
owned.length === 0 ||
|
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
|
||||||
owned.some(
|
owned.some(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
export * as ExecuteTool from "./execute"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CodeMode,
|
||||||
|
ExecuteInputSchema,
|
||||||
|
Tool,
|
||||||
|
toolError,
|
||||||
|
type DataValue,
|
||||||
|
type ExecuteResult,
|
||||||
|
type ToolCallHooks,
|
||||||
|
type ToolDefinition,
|
||||||
|
} from "@opencode-ai/codemode"
|
||||||
|
import { ToolOutput } from "@opencode-ai/llm"
|
||||||
|
import { Effect, Ref, Schema } from "effect"
|
||||||
|
import { definition, make, settle, type AnyTool } from "./tool"
|
||||||
|
|
||||||
|
const ExecuteFile = Schema.Struct({
|
||||||
|
data: Schema.String,
|
||||||
|
mime: Schema.String,
|
||||||
|
name: Schema.optionalKey(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExecuteCall = Schema.Struct({
|
||||||
|
tool: Schema.String,
|
||||||
|
status: Schema.Literals(["running", "completed", "error"]),
|
||||||
|
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ExecuteCall = typeof ExecuteCall.Type
|
||||||
|
|
||||||
|
const ExecuteMetadata = Schema.Struct({
|
||||||
|
toolCalls: Schema.Array(ExecuteCall),
|
||||||
|
error: Schema.optionalKey(Schema.Literal(true)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExecuteOutput = Schema.Struct({
|
||||||
|
output: Schema.String,
|
||||||
|
toolCalls: Schema.Array(ExecuteCall),
|
||||||
|
error: Schema.optionalKey(Schema.Literal(true)),
|
||||||
|
files: Schema.Array(ExecuteFile),
|
||||||
|
})
|
||||||
|
|
||||||
|
type CollectedFiles = {
|
||||||
|
readonly index: number
|
||||||
|
readonly files: Array<typeof ExecuteFile.Type>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Registration {
|
||||||
|
readonly identity: object
|
||||||
|
readonly tool: AnyTool
|
||||||
|
readonly name: string
|
||||||
|
readonly group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const create = (options: {
|
||||||
|
readonly registrations: ReadonlyMap<string, Registration>
|
||||||
|
readonly current: (name: string) => Registration | undefined
|
||||||
|
}) => {
|
||||||
|
const runtime = (
|
||||||
|
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||||
|
hooks?: ToolCallHooks,
|
||||||
|
) => {
|
||||||
|
const tools: Record<string, ToolDefinition<never> | Record<string, ToolDefinition<never>>> = {}
|
||||||
|
for (const [name, registration] of options.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),
|
||||||
|
})
|
||||||
|
if (registration.group === undefined) {
|
||||||
|
const path = registration.name
|
||||||
|
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred 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) {
|
||||||
|
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
|
||||||
|
group[path] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entries: Record<string, ToolDefinition<never>> = {}
|
||||||
|
entries[path] = value
|
||||||
|
tools[namespace] = entries
|
||||||
|
}
|
||||||
|
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||||
|
}
|
||||||
|
const discovery = runtime(() => Effect.fail(toolError("Execute context is unavailable")))
|
||||||
|
return make({
|
||||||
|
description: discovery.instructions(),
|
||||||
|
input: ExecuteInputSchema,
|
||||||
|
output: ExecuteOutput,
|
||||||
|
structured: ExecuteMetadata,
|
||||||
|
toStructuredOutput: ({ output }) => ({
|
||||||
|
toolCalls: output.toolCalls,
|
||||||
|
...(output.error ? { error: true as const } : {}),
|
||||||
|
}),
|
||||||
|
toModelOutput: ({ output }) => [
|
||||||
|
{ type: "text" as const, text: output.output },
|
||||||
|
...output.files.map((file) => ({
|
||||||
|
type: "file" as const,
|
||||||
|
data: file.data,
|
||||||
|
mime: file.mime,
|
||||||
|
...(file.name === undefined ? {} : { name: file.name }),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
execute: ({ code }, context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const callIndex = yield* Ref.make(0)
|
||||||
|
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||||
|
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||||
|
// TODO: Publish live call-list updates once V2 has a generic tool progress API.
|
||||||
|
const finalCalls = Ref.get(calls).pipe(
|
||||||
|
Effect.map((items) =>
|
||||||
|
items.map((call) => (call.status === "running" ? { ...call, status: "error" as const } : call)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const result = yield* runtime(
|
||||||
|
(name, registration, input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||||
|
const current = options.current(name)
|
||||||
|
if (!current || current.identity !== registration.identity)
|
||||||
|
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
|
||||||
|
const output = yield* settle(
|
||||||
|
current.tool,
|
||||||
|
{ type: "tool-call", id: context.toolCallID, name, input },
|
||||||
|
{
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
assistantMessageID: context.assistantMessageID,
|
||||||
|
toolCallID: context.toolCallID,
|
||||||
|
},
|
||||||
|
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||||
|
const outputFileParts = outputFiles(output)
|
||||||
|
if (outputFileParts.length > 0)
|
||||||
|
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||||
|
return output.structured
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onToolCallStart: ({ index, name, input }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const shown = displayInput(input)
|
||||||
|
yield* Ref.update(calls, (items) => {
|
||||||
|
const next = [...items]
|
||||||
|
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
onToolCallEnd: ({ index, outcome }) =>
|
||||||
|
Ref.update(calls, (items) => {
|
||||||
|
const current = items[index]
|
||||||
|
if (!current) return items
|
||||||
|
const next = [...items]
|
||||||
|
next[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).execute(code)
|
||||||
|
const toolCalls = yield* finalCalls
|
||||||
|
const collected = (yield* Ref.get(files))
|
||||||
|
.toSorted((left, right) => left.index - right.index)
|
||||||
|
.flatMap((item) => item.files)
|
||||||
|
const output = formatResult(result)
|
||||||
|
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (input === null || input === undefined) return
|
||||||
|
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||||
|
if (Object.keys(input).length === 0) return
|
||||||
|
return input as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResult(result: ExecuteResult) {
|
||||||
|
const output = result.ok
|
||||||
|
? formatValue(result.value)
|
||||||
|
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
if (!result.logs || result.logs.length === 0) return output
|
||||||
|
const logs = `Logs:\n${result.logs.join("\n")}`
|
||||||
|
return output === "" ? logs : `${output}\n\n${logs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: DataValue) {
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return JSON.stringify(value, null, 2) ?? String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
|
||||||
|
return output.content.flatMap((part) => {
|
||||||
|
if (part.type !== "file") return []
|
||||||
|
const prefix = `data:${part.mime};base64,`
|
||||||
|
if (!part.uri.startsWith(prefix)) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
data: part.uri.slice(prefix.length),
|
||||||
|
mime: part.mime,
|
||||||
|
...(part.name === undefined ? {} : { name: part.name }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
|||||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
@@ -12,10 +13,10 @@ import { Tools } from "./tools"
|
|||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry and permission action name for an MCP tool.
|
* Registry group and permission action names for MCP tools.
|
||||||
*/
|
*/
|
||||||
export const name = (server: string, tool: string) =>
|
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
`${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -35,72 +36,81 @@ export const layer = Layer.effectDiscard(
|
|||||||
for (const tool of yield* mcp.tools()) {
|
for (const tool of yield* mcp.tools()) {
|
||||||
const group = groups.get(tool.server) ?? {}
|
const group = groups.get(tool.server) ?? {}
|
||||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||||
group[tool.name] = Tool.make({
|
group[tool.name] = Tool.withPermission(
|
||||||
description: tool.description ?? "",
|
Tool.make({
|
||||||
jsonSchema: {
|
description: tool.description ?? "",
|
||||||
...schema,
|
jsonSchema: {
|
||||||
type: "object",
|
...schema,
|
||||||
properties: schema.properties ?? {},
|
type: "object",
|
||||||
additionalProperties: false,
|
properties: schema.properties ?? {},
|
||||||
},
|
additionalProperties: false,
|
||||||
execute: (input, context) =>
|
},
|
||||||
Effect.gen(function* () {
|
execute: (input, context) =>
|
||||||
yield* permission.assert({
|
Effect.gen(function* () {
|
||||||
action: name(tool.server, tool.name),
|
yield* permission.assert({
|
||||||
resources: ["*"],
|
action: name(tool.server, tool.name),
|
||||||
save: ["*"],
|
resources: ["*"],
|
||||||
metadata: {},
|
save: ["*"],
|
||||||
sessionID: context.sessionID,
|
metadata: {},
|
||||||
agent: context.agent,
|
sessionID: context.sessionID,
|
||||||
source: {
|
agent: context.agent,
|
||||||
type: "tool",
|
source: {
|
||||||
messageID: context.assistantMessageID,
|
type: "tool",
|
||||||
callID: context.toolCallID,
|
messageID: context.assistantMessageID,
|
||||||
},
|
callID: context.toolCallID,
|
||||||
})
|
},
|
||||||
const result = yield* mcp
|
|
||||||
.callTool({
|
|
||||||
server: tool.server,
|
|
||||||
name: tool.name,
|
|
||||||
args: (input ?? {}) as Record<string, unknown>,
|
|
||||||
})
|
})
|
||||||
.pipe(
|
const result = yield* mcp
|
||||||
Effect.catchTags({
|
.callTool({
|
||||||
"MCP.NotFoundError": (error) =>
|
server: tool.server,
|
||||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
name: tool.name,
|
||||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
args: (input ?? {}) as Record<string, unknown>,
|
||||||
}),
|
})
|
||||||
)
|
.pipe(
|
||||||
if (result.isError)
|
Effect.catchTags({
|
||||||
return yield* new ToolFailure({
|
"MCP.NotFoundError": (error) =>
|
||||||
message:
|
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||||
result.content
|
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
}),
|
||||||
.join("\n")
|
)
|
||||||
.trim() || "MCP tool returned an error",
|
if (result.isError)
|
||||||
})
|
return yield* new ToolFailure({
|
||||||
return {
|
message:
|
||||||
structured: result.structured ?? {},
|
result.content
|
||||||
content: result.content.map((part) =>
|
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||||
|
.join("\n")
|
||||||
|
.trim() || "MCP tool returned an error",
|
||||||
|
})
|
||||||
|
const content = result.content.map((part) =>
|
||||||
part.type === "text"
|
part.type === "text"
|
||||||
? { type: "text" as const, text: part.text }
|
? { type: "text" as const, text: part.text }
|
||||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||||
|
)
|
||||||
|
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||||
|
return {
|
||||||
|
structured: result.structured ?? (text === "" ? null : text),
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||||
),
|
),
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ToolFailure
|
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
|
||||||
),
|
),
|
||||||
),
|
}),
|
||||||
})
|
name(tool.server, tool.name),
|
||||||
|
)
|
||||||
groups.set(tool.server, group)
|
groups.set(tool.server, group)
|
||||||
}
|
}
|
||||||
const next = yield* Scope.fork(scope)
|
const next = yield* Scope.fork(scope)
|
||||||
yield* Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), {
|
yield* Effect.forEach(
|
||||||
discard: true,
|
groups,
|
||||||
}).pipe(Scope.provide(next), Effect.orDie)
|
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
|
||||||
|
{
|
||||||
|
discard: true,
|
||||||
|
},
|
||||||
|
).pipe(Scope.provide(next), Effect.orDie)
|
||||||
if (current) yield* Scope.close(current, Exit.void)
|
if (current) yield* Scope.close(current, Exit.void)
|
||||||
current = next
|
current = next
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ export * as ToolRegistry from "./registry"
|
|||||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import type { AgentV2 } from "../agent"
|
import type { AgentV2 } from "../agent"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { SessionMessage } from "../session/message"
|
import { SessionMessage } from "../session/message"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
import { Wildcard } from "../util/wildcard"
|
import { Wildcard } from "../util/wildcard"
|
||||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
import { ExecuteTool } from "./execute"
|
||||||
|
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
import { ToolHooks } from "./hooks"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
@@ -61,18 +63,8 @@ const registryLayer = Layer.effect(
|
|||||||
}
|
}
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||||
|
|
||||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
|
||||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
|
||||||
if (!registration)
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
type: "error" as const,
|
|
||||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (advertised && registration.identity !== advertised)
|
|
||||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
|
||||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
|
||||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||||
tool: input.call.name,
|
tool: input.call.name,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -83,7 +75,7 @@ const registryLayer = Layer.effect(
|
|||||||
}
|
}
|
||||||
yield* toolHooks.runBefore(beforeEvent)
|
yield* toolHooks.runBefore(beforeEvent)
|
||||||
const pending = yield* settle(
|
const pending = yield* settle(
|
||||||
registration.tool,
|
tool,
|
||||||
{ ...input.call, input: beforeEvent.input },
|
{ ...input.call, input: beforeEvent.input },
|
||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -135,10 +127,29 @@ const registryLayer = Layer.effect(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
|
||||||
|
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||||
|
if (!registration)
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
type: "error" as const,
|
||||||
|
value: `Stale tool call: ${input.call.name}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (registration.identity !== advertised)
|
||||||
|
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||||
|
return yield* settleTool(input, registration.tool)
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||||
const entries = registrationEntries(tools, options?.group)
|
const entries = registrationEntries(tools, options?.group)
|
||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
|
const reserved = options?.deferred ? 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' }),
|
||||||
|
)
|
||||||
yield* Effect.uninterruptible(
|
yield* Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const token = {}
|
const token = {}
|
||||||
@@ -180,16 +191,29 @@ const registryLayer = Layer.effect(
|
|||||||
for (const [name, registration] of registrations) {
|
for (const [name, registration] of registrations) {
|
||||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||||
if (
|
if (
|
||||||
registration.deferred ||
|
|
||||||
wrongEditTool ||
|
wrongEditTool ||
|
||||||
|
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
|
||||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
||||||
)
|
)
|
||||||
registrations.delete(name)
|
registrations.delete(name)
|
||||||
}
|
}
|
||||||
|
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
||||||
|
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
|
||||||
|
const execute =
|
||||||
|
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
||||||
|
? ExecuteTool.create({
|
||||||
|
registrations: deferred,
|
||||||
|
current: (name) => local.get(name)?.at(-1)?.registration,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
|
definitions: [
|
||||||
|
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||||
|
...(execute ? [definition("execute", execute)] : []),
|
||||||
|
],
|
||||||
settle: (input) => {
|
settle: (input) => {
|
||||||
const registration = registrations.get(input.call.name)
|
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||||
|
const registration = direct.get(input.call.name)
|
||||||
if (registration) return settleWith(input, registration.identity)
|
if (registration) return settleWith(input, registration.identity)
|
||||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -79,12 +79,17 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
|||||||
const permission = yield* Deferred.make<void>()
|
const permission = yield* Deferred.make<void>()
|
||||||
decision = Deferred.await(permission)
|
decision = Deferred.await(permission)
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* waitForTool(registry, "demo_search")
|
yield* waitForTool(registry, "execute")
|
||||||
|
|
||||||
const fiber = yield* settleTool(registry, {
|
const fiber = yield* settleTool(registry, {
|
||||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call_mcp_permission", name: "demo_search", input: {} },
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call_mcp_permission",
|
||||||
|
name: "execute",
|
||||||
|
input: { code: "return await tools.demo.search({})" },
|
||||||
|
},
|
||||||
}).pipe(Effect.forkScoped)
|
}).pipe(Effect.forkScoped)
|
||||||
expect(yield* Deferred.await(assertion)).toEqual({
|
expect(yield* Deferred.await(assertion)).toEqual({
|
||||||
action: "demo_search",
|
action: "demo_search",
|
||||||
@@ -113,15 +118,23 @@ it.effect("does not call MCP when permission is rejected", () =>
|
|||||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||||
decision = Effect.fail(new PermissionV2.RejectedError())
|
decision = Effect.fail(new PermissionV2.RejectedError())
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* waitForTool(registry, "demo_search")
|
yield* waitForTool(registry, "execute")
|
||||||
|
|
||||||
expect(
|
const settlement = yield* settleTool(registry, {
|
||||||
yield* settleTool(registry, {
|
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
|
||||||
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
|
...toolIdentity,
|
||||||
...toolIdentity,
|
call: {
|
||||||
call: { type: "tool-call", id: "call_mcp_rejected", name: "demo_search", input: {} },
|
type: "tool-call",
|
||||||
}),
|
id: "call_mcp_rejected",
|
||||||
).toEqual({ result: { type: "error", value: "Unable to execute demo_search" } })
|
name: "execute",
|
||||||
|
input: { code: "return await tools.demo.search({})" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
|
||||||
|
expect(settlement.output?.structured).toEqual({
|
||||||
|
toolCalls: [{ tool: "demo.search", status: "error" }],
|
||||||
|
error: true,
|
||||||
|
})
|
||||||
expect(calls).toBe(0)
|
expect(calls).toBe(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe("PluginV2", () => {
|
|||||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
||||||
"plain",
|
"plain",
|
||||||
"context_7_look_up",
|
"context_7_look_up",
|
||||||
|
"execute",
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user