From fdd65cb418a6e5a8b371a48b9f3a76fa04410a5e Mon Sep 17 00:00:00 2001 From: willytop8 Date: Sun, 18 Oct 2026 22:51:43 -0500 Subject: [PATCH] fix(v2): point models at Code Mode for goal tools OpenCode 2 exposes plugin tools only inside the execute tool. The goal prompts name tools bare, so models called goal_status directly and reported the goal tools unavailable. Add a system note when a request offers execute but no direct goal tool. Fixes #85 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ src/v2-bridge.js | 38 ++++++++++++++++++++++++++++++++------ test/v2-bridge.test.js | 21 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a58abe..e06089a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- On OpenCode 2, tell the model where the goal tools live. OpenCode 2's Code Mode offers plugin tools only inside the `execute` tool (as `tools.goal_status()` and so on), while the plugin's prompts name the tools bare, so some models called `goal_status` directly and reported the goal tools unavailable (#85). When a request offers `execute` and no direct goal tool, the system prompt now gets a one-line note pointing the model at `execute` and the Code Mode catalog. Verified on live OpenCode 2.0.16 and 2.0.31 hosts, with the package loaded from global config as in the report: the tools were always registered in the catalog. + ## 0.11.0 — 2026-09-30 - Normalize directory separators with a linear scan, preventing adversarial location strings from causing regular-expression backtracking. diff --git a/src/v2-bridge.js b/src/v2-bridge.js index 7a1eac9..f574f6c 100644 --- a/src/v2-bridge.js +++ b/src/v2-bridge.js @@ -759,16 +759,26 @@ export function createV2Setup(goalPluginFactory) { // V1 `experimental.chat.system.transform`: injects the goal block into the // system prompt of every model request. + const exposesGoalTools = + isPlainObject(hooks.tool) && typeof hooks.tool.goal_status?.execute === "function" await track( await ctx.session.hook("context", async (event) => { - if (typeof hooks["experimental.chat.system.transform"] !== "function") return + const transform = typeof hooks["experimental.chat.system.transform"] === "function" + const note = exposesGoalTools && toolsAreCodeModeOnly(event.tools) + if (!transform && !note) return const output = { system: Array.isArray(event.system) ? [...event.system] : [] } - try { - await hooks["experimental.chat.system.transform"]({ sessionID: event.sessionID }, output) - } catch (error) { - hookError("experimental.chat.system.transform", error) - return + if (transform) { + try { + await hooks["experimental.chat.system.transform"]({ sessionID: event.sessionID }, output) + } catch (error) { + hookError("experimental.chat.system.transform", error) + return + } } + // Code Mode hosts offer plugin tools only inside `execute`. The goal + // prompts name tools bare, so some models call them directly and + // report them unavailable (issue #85). Say where they live. + if (note) output.system.push(CODE_MODE_GOAL_TOOLS_NOTE) if (Array.isArray(output.system)) event.system = output.system.map(toSystemPart) }), ) @@ -1112,6 +1122,21 @@ export function createV2Setup(goalPluginFactory) { } } +export const CODE_MODE_GOAL_TOOLS_NOTE = + "Goal tools (goal_status, goal_set, goal_pause, goal_resume, goal_block, goal_complete and the rest) " + + "are not direct tools on this host. Call them inside the `execute` tool, for example " + + "`return await tools.goal_status()`, using the signatures in the Code Mode catalog." + +/** True when the request offers `execute` (Code Mode) but no goal tool directly. */ +function toolsAreCodeModeOnly(tools) { + const names = Array.isArray(tools) + ? tools.map((tool) => (typeof tool === "string" ? tool : tool?.name)) + : isPlainObject(tools) + ? Object.keys(tools) + : [] + return names.includes("execute") && !names.includes("goal_status") +} + function toSystemPart(entry) { if (typeof entry === "string") return { type: "text", text: entry } if (!isPlainObject(entry)) return { type: "text", text: "" } @@ -1121,6 +1146,7 @@ function toSystemPart(entry) { } export const v2BridgeInternals = Object.freeze({ + toolsAreCodeModeOnly, MARKER_KEY, GOAL_COMMAND_DESCRIPTION, createHostState, diff --git a/test/v2-bridge.test.js b/test/v2-bridge.test.js index e6e6534..d7cae2d 100644 --- a/test/v2-bridge.test.js +++ b/test/v2-bridge.test.js @@ -5,6 +5,7 @@ import { z } from "zod" import goalPlugin, { GoalPlugin, testInternals } from "../src/goal-plugin.js" import { applyNativeGoalConfig } from "../src/native-agent-config.js" import { + CODE_MODE_GOAL_TOOLS_NOTE, GOAL_COMMAND_DESCRIPTION, createHostState, createV2Client, @@ -616,6 +617,26 @@ test("same-location setup retires the previous instance instead of stealing a li assert.equal(disposals, 2) }) +test("Code Mode requests are told goal tools live inside execute (#85)", async () => { + const systemFor = async (pluginOptions, tools) => { + const { ctx, registered } = createFakeCtx({ pluginOptions: { persistState: false, ...pluginOptions } }) + const cleanup = await createV2Setup(GoalPlugin)(ctx) + try { + const event = { sessionID: "s1", system: [{ type: "text", text: "host prompt" }], tools } + for (const hook of registered.sessionHooks.context) await hook(event) + return event.system.map((part) => part.text) + } finally { + await cleanup() + } + } + const codeMode = await systemFor({}, { read: {}, execute: {} }) + assert.ok(codeMode.includes(CODE_MODE_GOAL_TOOLS_NOTE)) + assert.equal(codeMode[0], "host prompt", "host system parts are preserved") + assert.ok(!(await systemFor({}, { read: {}, execute: {}, goal_status: {} })).includes(CODE_MODE_GOAL_TOOLS_NOTE), "direct tools need no note") + assert.ok(!(await systemFor({}, { read: {} })).includes(CODE_MODE_GOAL_TOOLS_NOTE), "no Code Mode, no note") + assert.ok(!(await systemFor({ registerTools: false }, { execute: {} })).includes(CODE_MODE_GOAL_TOOLS_NOTE), "no registered tools, no note") +}) + test("setup registers every V2 surface the goal workflow needs", async () => { const { ctx, registered } = createFakeCtx() const cleanup = await createV2Setup(GoalPlugin)(ctx)