Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 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.
- Never send a liveness signal to the host's own process when checking a persistence lease owner. On Windows, runtimes that emulate POSIX signals may treat `kill(pid, 0)` as a real termination, which matches reports of the OpenCode service dying silently with the plugin installed (#81).
- On OpenCode 2, tear down everything a partially failed `setup` already started (event subscription, timers, hook registrations, and the goal core with its persistence lease) instead of leaking it on every location boot.
- On Windows, treat `/` and `\` as the same separator when matching a location, so a reload with a differently spelled path retires the previous instance instead of running beside it.
Expand Down
38 changes: 32 additions & 6 deletions src/v2-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -819,16 +819,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)
}),
)
Expand Down Expand Up @@ -1151,6 +1161,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: "" }
Expand All @@ -1160,6 +1185,7 @@ function toSystemPart(entry) {
}

export const v2BridgeInternals = Object.freeze({
toolsAreCodeModeOnly,
MARKER_KEY,
GOAL_COMMAND_DESCRIPTION,
createHostState,
Expand Down
21 changes: 21 additions & 0 deletions test/v2-bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -675,6 +676,26 @@ test("on Windows, either separator spelling names the same location", () => {
}
})

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)
Expand Down
Loading