From d65ecd4a90cf3927bd38f72987a6ac8b0d857545 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:20:56 -0500 Subject: [PATCH] feat(core): expose deferred tools through execute (#35361) --- bun.lock | 1 + packages/codemode/codemode.md | 6 + packages/core/package.json | 1 + packages/core/src/flag/flag.ts | 3 + packages/core/src/mcp/guidance.ts | 12 +- packages/core/src/tool/execute.ts | 211 +++++++++++++++++++++++++++++ packages/core/src/tool/mcp.ts | 130 ++++++++++-------- packages/core/src/tool/registry.ts | 58 +++++--- packages/core/test/mcp.test.ts | 33 +++-- packages/core/test/plugin.test.ts | 1 + 10 files changed, 367 insertions(+), 89 deletions(-) create mode 100644 packages/core/src/tool/execute.ts diff --git a/bun.lock b/bun.lock index 46382ce9551..3ca6fd949d1 100644 --- a/bun.lock +++ b/bun.lock @@ -321,6 +321,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 41b801044dc..14fe18b8752 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -1132,6 +1132,12 @@ Post-MVP (logged, not blocking an experimental flag): - [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`, `session/system.ts:110-126`) still inject prose referencing server-native tool 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) diff --git a/packages/core/package.json b/packages/core/package.json index 56bcbb53bab..84a51e61d2c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,6 +90,7 @@ "@ff-labs/fff-bun": "0.9.4", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 1c975040dc6..b33c20c1ab9 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -52,6 +52,9 @@ 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 eed26170b06..0963a69d234 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -1,6 +1,7 @@ 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" @@ -17,6 +18,11 @@ 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))}]\`.`, + ] + : []), ...server.instructions.split("\n").map((line) => ` ${line}`), " ", ]) @@ -64,15 +70,17 @@ export const layer = Layer.effect( load: Effect.fn("McpGuidance.load")(function* (selection) { const agent = selection.info 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()], { 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 .filter((item) => { const owned = tools.filter((tool) => tool.server === item.server) return ( - owned.length === 0 || + (!Flag.CODEMODE_ENABLED && owned.length === 0) || owned.some( (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts new file mode 100644 index 00000000000..67d1b21eafe --- /dev/null +++ b/packages/core/src/tool/execute.ts @@ -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 +} + +export interface Registration { + readonly identity: object + readonly tool: AnyTool + readonly name: string + readonly group?: string +} + +export const create = (options: { + readonly registrations: ReadonlyMap + readonly current: (name: string) => Registration | undefined +}) => { + const runtime = ( + invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, + hooks?: ToolCallHooks, + ) => { + const tools: Record | Record>> = {} + 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> = {} + entries[path] = value + tools[namespace] = entries + } + return CodeMode.make({ 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>([]) + const calls = yield* Ref.make>([]) + // 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 | 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 +} + +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 { + 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 }), + }, + ] + }) +} diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 23d11d06a03..8cb0117ebbf 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -5,6 +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" @@ -12,10 +13,10 @@ import { Tools } from "./tools" 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) => - `${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}` +export const group = (server: string) => server.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( Effect.gen(function* () { @@ -35,72 +36,81 @@ export const layer = Layer.effectDiscard( for (const tool of yield* mcp.tools()) { const group = groups.get(tool.server) ?? {} const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema - group[tool.name] = Tool.make({ - description: tool.description ?? "", - jsonSchema: { - ...schema, - type: "object", - properties: schema.properties ?? {}, - additionalProperties: false, - }, - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name(tool.server, tool.name), - resources: ["*"], - save: ["*"], - metadata: {}, - sessionID: context.sessionID, - agent: context.agent, - source: { - type: "tool", - messageID: context.assistantMessageID, - callID: context.toolCallID, - }, - }) - const result = yield* mcp - .callTool({ - server: tool.server, - name: tool.name, - args: (input ?? {}) as Record, + group[tool.name] = Tool.withPermission( + Tool.make({ + description: tool.description ?? "", + jsonSchema: { + ...schema, + type: "object", + properties: schema.properties ?? {}, + additionalProperties: false, + }, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name(tool.server, tool.name), + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, }) - .pipe( - Effect.catchTags({ - "MCP.NotFoundError": (error) => - new ToolFailure({ message: `MCP server "${error.server}" is not available` }), - "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), - }), - ) - if (result.isError) - return yield* new ToolFailure({ - message: - result.content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - .trim() || "MCP tool returned an error", - }) - return { - structured: result.structured ?? {}, - content: result.content.map((part) => + const result = yield* mcp + .callTool({ + server: tool.server, + name: tool.name, + args: (input ?? {}) as Record, + }) + .pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => + new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), + ) + if (result.isError) + return yield* new ToolFailure({ + message: + result.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() || "MCP tool returned an error", + }) + const content = result.content.map((part) => part.type === "text" ? { type: "text" as const, text: part.text } : { 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) } const next = yield* Scope.fork(scope) - yield* Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), { - discard: true, - }).pipe(Scope.provide(next), Effect.orDie) + yield* Effect.forEach( + groups, + ([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) current = next }), diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index dcdbb1c8887..de11c10e6fd 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,12 +3,14 @@ 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" import { ToolOutputStore } from "../tool-output-store" 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 { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" @@ -61,18 +63,8 @@ const registryLayer = Layer.effect( } const local = new Map>() - 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: 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 settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { + // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. const beforeEvent: ToolHooks.BeforeEvent = { tool: input.call.name, sessionID: input.sessionID, @@ -83,7 +75,7 @@ const registryLayer = Layer.effect( } yield* toolHooks.runBefore(beforeEvent) const pending = yield* settle( - registration.tool, + tool, { ...input.call, input: beforeEvent.input }, { 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({ 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") + if (reserved) + return yield* Effect.fail( + new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), + ) yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} @@ -180,16 +191,29 @@ const registryLayer = Layer.effect( for (const [name, registration] of registrations) { const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch if ( - registration.deferred || wrongEditTool || + (registration.deferred && !Flag.CODEMODE_ENABLED) || whollyDisabled(permission(registration.tool, name), input.permissions ?? []) ) 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 { - 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) => { - 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) return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) }, diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index f52b223dcea..ffcc2d3b896 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -79,12 +79,17 @@ it.effect("waits for permission before calling an MCP tool", () => const permission = yield* Deferred.make() decision = Deferred.await(permission) const registry = yield* ToolRegistry.Service - yield* waitForTool(registry, "demo_search") + yield* waitForTool(registry, "execute") const fiber = yield* settleTool(registry, { sessionID: SessionV2.ID.make("ses_mcp_permission"), ...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) expect(yield* Deferred.await(assertion)).toEqual({ action: "demo_search", @@ -113,15 +118,23 @@ it.effect("does not call MCP when permission is rejected", () => assertion = yield* Deferred.make() decision = Effect.fail(new PermissionV2.RejectedError()) const registry = yield* ToolRegistry.Service - yield* waitForTool(registry, "demo_search") + yield* waitForTool(registry, "execute") - expect( - yield* settleTool(registry, { - sessionID: SessionV2.ID.make("ses_mcp_rejected"), - ...toolIdentity, - call: { type: "tool-call", id: "call_mcp_rejected", name: "demo_search", input: {} }, - }), - ).toEqual({ result: { type: "error", value: "Unable to execute demo_search" } }) + const settlement = yield* settleTool(registry, { + sessionID: SessionV2.ID.make("ses_mcp_rejected"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_rejected", + 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) }), ) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 1a60decb0bb..266d4878623 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -154,6 +154,7 @@ describe("PluginV2", () => { expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([ "plain", "context_7_look_up", + "execute", ]) }), )