Compare commits

...
Author SHA1 Message Date
rekram1-node 6189181c4e feat(core): remind a switched model which editing tools it lacks
On each primary request, compare the tool calls in the outgoing history with
the tools the request actually offers. When calls made by a different model
name tools this model does not have, add one reminder before the prompt and
persist it as a synthetic message, recording which tools it named so later
requests and later switches to models with the same tools do not repeat it.
Runs after the patch plugin so it sees the editing tools left for this model.
2026-09-24 14:52:28 -05:00
3 changed files with 272 additions and 0 deletions
+3
View File
@@ -91,6 +91,7 @@ import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelSwitchPlugin } from "./model-switch.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { McpCodeModeDefaultsPlugin } from "./mcp-codemode-defaults.js"
import { ProviderPlugins } from "./provider.js"
@@ -228,6 +229,8 @@ const pre = [
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
// Sees the editing tools the patch plugin left for this model.
ModelSwitchPlugin.Plugin,
// Render model prompts after the patch plugin selects the available editing tools.
...OptimizePlugin.Plugins,
IdentityPlugin.Plugin,
+69
View File
@@ -0,0 +1,69 @@
export * as ModelSwitchPlugin from "./model-switch.js"
import { Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionMessage } from "@opencode/schema/session-message"
import { Effect, Option, Schema } from "effect"
const key = "opencode.model-switch"
const decodeAnnounced = Schema.decodeUnknownOption(Schema.Array(Schema.String))
const list = (names: ReadonlyArray<string>) => names.map((name) => `\`${name}\``).join(" and ")
/**
* When a request offers a different model a history whose tool calls name
* tools this request does not offer, remind the model once which of those
* tools it lacks. The reminder is persisted so later requests, and later
* switches to models with the same tools, do not repeat it.
*/
export const Plugin = define({
id: key,
effect: Effect.fn(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const offered = new Set(Object.keys(event.tools))
// Cheap check first: does the outgoing request call any tool it does not offer?
const outgoing = event.messages.flatMap((message) => (message.role === "assistant" ? message.content : []))
if (!outgoing.some((part) => part.type === "tool-call" && !offered.has(part.name))) return
// Only calls another model made count; a tool this model used and then lost is not a switch.
const history = yield* ctx.session
.context({ sessionID: event.sessionID })
.pipe(Effect.orElseSucceed((): ReadonlyArray<SessionMessage.Info> => []))
const foreign = history.filter(
(message): message is SessionMessage.Assistant =>
message.type === "assistant" &&
(message.model.providerID !== event.model.providerID || message.model.id !== event.model.id),
)
const calls = foreign.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool" ? [part.name] : [])),
)
const removed = [...new Set(calls)].filter((name) => !offered.has(name))
if (removed.length === 0) return
// Say nothing if the most recent reminder already named all of them.
const last = history.findLast((message) => message.metadata?.[key] !== undefined)
const announced = Option.getOrUndefined(decodeAnnounced(last?.metadata?.[key])) ?? []
if (removed.every((name) => announced.includes(name))) return
// GPT models edit through patch; everything else uses edit and write.
const added = (removed.includes("patch") ? ["edit", "write"] : ["patch"]).filter((name) => offered.has(name))
const text = `<system-reminder>
You are continuing a conversation started by a different model. Some tools it used are not available to you. ${list(removed)} ${removed.length === 1 ? "is" : "are"} no longer available and must not be called${added.length > 0 ? `; use ${list(added)} instead` : ""}.
</system-reminder>`
// Before the user's prompt, matching where agent-switch reminders land.
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
event.messages.splice(at, 0, Message.user(text))
yield* ctx.session
.synthetic({ sessionID: event.sessionID, text, metadata: { [key]: removed }, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to persist model switch reminder", { sessionID: event.sessionID, cause }),
),
)
}),
)
}),
})
@@ -0,0 +1,200 @@
import { describe, expect } from "bun:test"
import { Message } from "@opencode/ai"
import type { SessionContext } from "@opencode/plugin/effect/session"
import { DateTime, Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Model } from "@opencode/core/model"
import { ModelSwitchPlugin } from "@opencode/core/plugin/model-switch"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { it } from "../lib/effect"
import { host } from "./host"
const sessionID = Session.ID.make("ses_model_switch_test")
const provider = Provider.ID.make("test")
const gpt = { providerID: provider, id: Model.ID.make("gpt-6-luna") }
const claude = { providerID: provider, id: Model.ID.make("claude-fable-5-1") }
const glm = { providerID: provider, id: Model.ID.make("glm-5.3-flash") }
const at = DateTime.makeUnsafe(0)
const patchNotice = [
"<system-reminder>",
"You are continuing a conversation started by a different model. Some tools it used are not available to you. `patch` is no longer available and must not be called; use `edit` and `write` instead.",
"</system-reminder>",
].join("\n")
const editNotice = [
"<system-reminder>",
"You are continuing a conversation started by a different model. Some tools it used are not available to you. `edit` and `write` are no longer available and must not be called; use `patch` instead.",
"</system-reminder>",
].join("\n")
/** One assistant turn in the durable history, with the tools it called. */
const turn = (id: string, model: Model.Ref, tools: ReadonlyArray<string>) =>
SessionMessage.Assistant.make({
id: SessionMessage.ID.make(id),
type: "assistant",
agent: Agent.ID.make("build"),
model,
content: tools.map((name, index) => ({
type: "tool",
id: `call_${id}_${index}`,
name,
state: { status: "completed", input: {}, content: [{ type: "text", text: "ok" }] },
time: { created: at, completed: at },
})),
cost: Money.USD.make(0),
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: at, completed: at },
})
/** A reminder this plugin persisted earlier. */
const reminder = (id: string, removed: ReadonlyArray<string>) =>
SessionMessage.Synthetic.make({
id: SessionMessage.ID.make(id),
type: "synthetic",
text: "earlier reminder",
metadata: { "opencode.model-switch": removed },
time: { created: at },
})
/** The same history as the model sees it: one tool call per name, plus the user's new prompt. */
const lowered = (history: ReadonlyArray<SessionMessage.Info>) => [
...history.flatMap((message) =>
message.type === "assistant"
? [
Message.assistant(
message.content.flatMap((part) =>
part.type === "tool" ? [{ type: "tool-call" as const, id: part.id, name: part.name, input: {} }] : [],
),
),
]
: [],
),
Message.user("Can you also add a --version flag?"),
]
/** Runs one request through the hook; returns the request messages after it and anything persisted. */
const request = Effect.fnUntraced(function* (
model: Model.Ref,
tools: ReadonlyArray<string>,
history: ReadonlyArray<SessionMessage.Info>,
) {
const persisted = new Array<{ text: string; metadata?: Record<string, unknown>; resume?: boolean }>()
let hook: ((input: SessionContext) => Effect.Effect<void>) | undefined
yield* ModelSwitchPlugin.Plugin.effect(
host({
session: {
hook: (name, callback) => {
if (name === "context") hook = callback as (input: SessionContext) => Effect.Effect<void>
return Effect.succeed({ dispose: Effect.void })
},
context: () => Effect.succeed(history),
synthetic: (input) => {
persisted.push({ text: input.text, metadata: input.metadata, resume: input.resume })
return Effect.succeed(
SessionInbox.Synthetic.make({
id: SessionMessage.ID.make("msg_model_switch_test"),
sessionID,
time: { created: at },
type: "synthetic",
payload: { text: input.text },
delivery: "steer",
}),
)
},
},
}),
)
if (!hook) return yield* Effect.die("model switch plugin did not register a context hook")
const event: SessionContext = {
sessionID,
agent: Agent.ID.make("build"),
model,
system: [],
messages: lowered(history),
tools: Object.fromEntries(tools.map((name) => [name, { description: name, input: { type: "object" } }])),
options: {},
}
yield* hook(event)
const injected = event.messages.flatMap((message) => {
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
return part?.type === "text" && part.text.startsWith("<system-reminder>") ? [part.text] : []
})
return { injected, persisted, last: event.messages.at(-1) }
})
describe("ModelSwitchPlugin", () => {
it.effect("reminds a model that lacks patch when another model's turns called it", () =>
Effect.gen(function* () {
const result = yield* request(
claude,
["read", "glob", "edit", "write"],
[turn("msg_1", gpt, ["read", "patch"]), turn("msg_2", gpt, ["patch", "glob"])],
)
expect(result.injected).toEqual([patchNotice])
expect(result.last?.role).toBe("user")
expect(result.persisted).toEqual([
{ text: patchNotice, metadata: { "opencode.model-switch": ["patch"] }, resume: false },
])
}),
)
it.effect("reminds a model that lacks edit and write, with singular phrasing for one tool", () =>
Effect.gen(function* () {
const both = yield* request(gpt, ["read", "patch"], [turn("msg_1", claude, ["edit", "write", "read"])])
expect(both.injected).toEqual([editNotice])
const editOnly = yield* request(gpt, ["read", "patch"], [turn("msg_1", claude, ["edit"])])
expect(editOnly.injected[0]).toContain(
"`edit` is no longer available and must not be called; use `patch` instead.",
)
}),
)
it.effect("only suggests replacements this request actually offers", () =>
Effect.gen(function* () {
const result = yield* request(claude, ["read"], [turn("msg_1", gpt, ["patch"])])
expect(result.injected[0]).toContain("`patch` is no longer available and must not be called.")
expect(result.injected[0]).not.toContain("instead")
}),
)
it.effect("stays silent when nothing in the history is missing from this request", () =>
Effect.gen(function* () {
// Same tools on both sides of the switch.
expect((yield* request(glm, ["edit", "write"], [turn("msg_1", claude, ["edit", "write"])])).injected).toEqual([])
// Only read-only tools were used before the switch.
expect(
(yield* request(claude, ["read", "grep", "glob", "edit"], [turn("msg_1", gpt, ["read", "grep", "glob"])]))
.injected,
).toEqual([])
// The same model made the calls; a missing tool here is not a model switch.
expect((yield* request(claude, ["read"], [turn("msg_1", claude, ["edit"])])).injected).toEqual([])
}),
)
it.effect("does not repeat a reminder that already covers the missing tools", () =>
Effect.gen(function* () {
// GPT wrote with patch, a first non-GPT model was reminded, now a second non-GPT model takes over.
const covered = yield* request(
glm,
["read", "edit", "write"],
[turn("msg_1", gpt, ["patch"]), reminder("msg_2", ["patch"]), turn("msg_3", claude, ["edit"])],
)
expect(covered.injected).toEqual([])
expect(covered.persisted).toEqual([])
// Switching back to GPT: the last reminder was about patch, so the edit calls still need one.
const back = yield* request(
gpt,
["read", "patch"],
[turn("msg_1", gpt, ["patch"]), reminder("msg_2", ["patch"]), turn("msg_3", claude, ["edit"])],
)
expect(back.injected).toEqual([editNotice.replace("`edit` and `write` are", "`edit` is")])
}),
)
})