Skip to content

Instantly share code, notes, and snippets.

@watert
Created July 13, 2026 10:48
Show Gist options
  • Select an option

  • Save watert/a54d2aef546fb08e5db9c6e056ca0371 to your computer and use it in GitHub Desktop.

Select an option

Save watert/a54d2aef546fb08e5db9c6e056ca0371 to your computer and use it in GitHub Desktop.
Grok Composer 2.5 Fast — Tool Call Schema Fix (OpenCode Plugin)
// Grok Composer tool call 参数规范化插件
// 修复 grok-composer-2.5-fast 因训练协议面向 Cursor, 导致 edit/read/write/todowrite 的 Schema 字段名不匹配
//
// ⚠️ 使用警告:
// 本插件的 tool.execute.before hook 对所有模型生效(虽然非 composer session 会 early return)
// 若已启用下方 tool 别名注册(StrReplace/read_file 等), 会给所有模型的初始 tool 列表增加额外上下文
// 除非主力模型就是 composer 2.5, 否则不建议启用 tool 别名部分
// 仅保留 execute.before 的字段映射是最小侵入方案
import type { Plugin } from "@opencode-ai/plugin"
// [如需启用 tool 别名] 取消注释: import { tool } from "@opencode-ai/plugin"
// [如需启用 tool 别名] 取消注释: import fs from "fs/promises"
// [如需启用 tool 别名] 取消注释: import path from "path"
// ============================================================
// [已注释] 以下为 tool 别名实现所需的辅助函数, 启用 tool 别名时取消注释
// ============================================================
// type AnyArgs = Record<string, unknown>
//
// function pickStr(a: AnyArgs, ...keys: string[]): string | undefined {
// for (const k of keys) {
// const v = a[k]; if (typeof v === "string" && v.length > 0) return v
// }
// return undefined
// }
//
// function pickBool(a: AnyArgs, ...keys: string[]): boolean | undefined {
// for (const k of keys) {
// const v = a[k]; if (typeof v === "boolean") return v
// }
// return undefined
// }
//
// function pickNum(a: AnyArgs, ...keys: string[]): number | undefined {
// for (const k of keys) {
// const v = a[k]
// if (typeof v === "number" && Number.isFinite(v)) return v
// if (typeof v === "string" && Number.isFinite(Number(v))) return Number(v)
// }
// return undefined
// }
//
// function resolveAbs(filePath: string, directory: string): string {
// return path.isAbsolute(filePath) ? filePath : path.resolve(directory, filePath)
// }
//
// // Cursor 风格 str_replace 工具: 读取文件 → 查找 oldString → 替换为 newString → 写回
// async function applyStrReplace(directory: string, raw: AnyArgs) {
// const mapped: string[] = []
// const filePath = pickStr(raw, "filePath", "file_path", "path")
// const oldString = pickStr(raw, "oldString", "old_string")
// const newString = pickStr(raw, "newString", "new_string")
// const replaceAll = pickBool(raw, "replaceAll", "replace_all") ?? false
// if (raw.file_path && !raw.filePath) mapped.push("file_path→filePath")
// if (raw.old_string && !raw.oldString) mapped.push("old_string→oldString")
// if (raw.new_string && !raw.newString) mapped.push("new_string→newString")
// if (raw.replace_all !== undefined && raw.replaceAll === undefined) mapped.push("replace_all→replaceAll")
// const missing: string[] = []
// if (!filePath) missing.push("filePath|file_path")
// if (oldString === undefined) missing.push("oldString|old_string")
// if (newString === undefined) missing.push("newString|new_string")
// if (missing.length) throw new Error(`StrReplace 参数缺失: ${missing.join(", ")}。实际收到键: [${Object.keys(raw).join(", ")}]`)
// if (oldString === newString) throw new Error("oldString 与 newString 相同,无改动")
// const abs = resolveAbs(filePath!, directory)
// let content: string
// try { content = await fs.readFile(abs, "utf8") }
// catch (e: any) { throw new Error(`无法读取文件 ${abs}: ${e?.code === "ENOENT" ? "文件不存在" : e?.message ?? e}`) }
// if (!content.includes(oldString!)) {
// const hint = content.length > 0 ? `文件共 ${content.length} 字符,前 80 字: ${JSON.stringify(content.slice(0, 80))}` : "文件为空"
// throw new Error(`oldString 在文件中未找到(需完全匹配,含空白与换行)。路径: ${abs}。${hint}`)
// }
// const next = replaceAll ? content.split(oldString!).join(newString!) : content.replace(oldString!, newString!)
// await fs.writeFile(abs, next, "utf8")
// return { output: `已写入 ${abs}${replaceAll ? "(replace_all)" : ""}`, mapped, normalized: { filePath: abs, oldString, newString, replaceAll } }
// }
//
// // Cursor 风格 read 工具: 按行号范围读取文件
// async function applyReadFile(directory: string, raw: AnyArgs) {
// const mapped: string[] = []
// const filePath = pickStr(raw, "filePath", "file_path", "path")
// const offset = pickNum(raw, "offset") ?? 1
// const limit = pickNum(raw, "limit") ?? 2000
// if (raw.path && !raw.filePath) mapped.push("path→filePath")
// if (raw.file_path && !raw.filePath) mapped.push("file_path→filePath")
// if (typeof raw.limit === "string") mapped.push("limit:string→number")
// if (!filePath) throw new Error(`read 参数缺失 filePath|file_path|path。收到: [${Object.keys(raw).join(", ")}]`)
// const abs = resolveAbs(filePath, directory)
// let text: string
// try { text = await fs.readFile(abs, "utf8") }
// catch (e: any) { throw new Error(`无法读取 ${abs}: ${e?.code === "ENOENT" ? "文件不存在" : e?.message ?? e}`) }
// const lines = text.split("\n")
// const start = Math.max(0, offset - 1)
// const slice = lines.slice(start, start + limit)
// const body = slice.map((line, i) => `${start + i + 1}: ${line}`).join("\n")
// return { output: body || "(empty file)", mapped }
// }
// ============================================================
// 核心插件: chat.params + tool.execute.before
// ============================================================
export const GrokComposerFix: Plugin = async ({ client }) => {
// sessionID → 是否为 composer 模型 (依据 model.id 或 api.id 含 "composer")
const sessionIsComposer = new Map<string, boolean>()
// 结构化日志 (写入 ~/.local/share/opencode/log/opencode.log)
const log = (level: "debug" | "info" | "warn" | "error", message: string, extra?: Record<string, unknown>) =>
client.app.log({ body: { service: "grok-composer-fix", level, message, extra } })
// [如需启用 system prompt 注入] 取消注释以下常量及 experimental.chat.system.transform hook
// 注意: system prompt 注入仅在 composer session 生效, 但 hook 本身对所有 session 都会触发判断
// const COMPOSER_TOOL_SYSTEM = [
// "[grok-composer-fix] 改文件可继续用 StrReplace / search_replace(已映射到本地写盘);推荐参数 file_path + old_string + new_string 或 camelCase。",
// "读文件可用 read_file / Read,写盘类错误会在工具输出里给出具体原因(非 invalid 兜底)。",
// "todowrite 参数: todos 必须是数组(不要序列化为字符串),每个 item 只需 {content, status, priority?},不要发 id/merge 等额外字段。",
// ].join("\n")
// ============================================================
// [已注释] Tool 别名注册
// 注册 StrReplace / search_replace / read_file 等 Cursor 风格工具名
// 启用后会出现在所有模型的 tool 列表中, 增加初始上下文开销
// 如果 composer 能通过 execute.before 的字段映射正常调用内置 edit/read, 无需启用
// ============================================================
// const makeStrReplaceTool = (id: string) =>
// tool({
// description: `Replace text in a file (OpenCode alias for \`edit\`). Tool id: ${id}. Accepts filePath/file_path, oldString/old_string, newString/new_string.`,
// args: {
// filePath: tool.schema.string().optional().describe("absolute or workspace-relative path"),
// file_path: tool.schema.string().optional(),
// path: tool.schema.string().optional(),
// oldString: tool.schema.string().optional(),
// old_string: tool.schema.string().optional(),
// newString: tool.schema.string().optional(),
// new_string: tool.schema.string().optional(),
// replaceAll: tool.schema.boolean().optional(),
// replace_all: tool.schema.boolean().optional(),
// },
// async execute(args, ctx) {
// if (!sessionIsComposer.get(ctx.sessionID))
// return { output: "StrReplace 别名工具仅对 grok composer 会话启用", metadata: { skipped: true } }
// try {
// const { output, mapped, normalized } = await applyStrReplace(ctx.directory, args as AnyArgs)
// if (mapped.length) await log("info", "alias tool args normalized", { sessionID: ctx.sessionID, tool: id, mapped, normalized })
// return { output, metadata: { aliasOf: "edit", mapped } }
// } catch (e: any) {
// const msg = e?.message ?? String(e)
// await log("warn", "alias StrReplace failed", { sessionID: ctx.sessionID, tool: id, error: msg, rawKeys: Object.keys(args as object) })
// throw new Error(msg)
// }
// },
// })
//
// const readAlias = tool({
// description: "Read file (alias for read). filePath / file_path / path; optional offset, limit.",
// args: {
// filePath: tool.schema.string().optional(),
// file_path: tool.schema.string().optional(),
// path: tool.schema.string().optional(),
// offset: tool.schema.union([tool.schema.number(), tool.schema.string()]).optional(),
// limit: tool.schema.union([tool.schema.number(), tool.schema.string()]).optional(),
// },
// async execute(args, ctx) {
// if (!sessionIsComposer.get(ctx.sessionID))
// return { output: "read_file 仅 composer 会话", metadata: { skipped: true } }
// try {
// const { output, mapped } = await applyReadFile(ctx.directory, args as AnyArgs)
// if (mapped.length) await log("info", "alias tool args normalized", { sessionID: ctx.sessionID, tool: "read_file", mapped })
// return { output, metadata: { aliasOf: "read", mapped } }
// } catch (e: any) {
// const msg = e?.message ?? String(e)
// await log("warn", "alias read failed", { sessionID: ctx.sessionID, error: msg })
// throw new Error(msg)
// }
// },
// })
return {
// [已注释] 如需启用 tool 别名, 取消注释下方 tool 注册块
// tool: {
// StrReplace: makeStrReplaceTool("StrReplace"),
// search_replace: makeStrReplaceTool("search_replace"),
// str_replace: makeStrReplaceTool("str_replace"),
// read_file: readAlias,
// Read: readAlias,
// },
// [已注释] 如需注入 composer 专属 system prompt, 取消注释
// "experimental.chat.system.transform": async (input, output) => {
// const id = input.model.id ?? ""
// const apiId = input.model.api?.id ?? ""
// if (!id.includes("composer") && !apiId.includes("composer")) return
// output.system = [...(output.system ?? []), COMPOSER_TOOL_SYSTEM]
// },
// 核心 hook 1: 每次 LLM turn 检测是否为 composer 模型, 缓存到 Map
// input 携带完整 Model 对象, 通过 model.id / model.api.id 判断
"chat.params": async (input) => {
const id = input.model.id ?? ""
const apiId = input.model.api?.id ?? ""
const isComposer = id.includes("composer") || apiId.includes("composer")
sessionIsComposer.set(input.sessionID, isComposer)
if (isComposer) {
await log("debug", "composer session detected", { sessionID: input.sessionID, modelID: id, apiID: apiId })
}
},
// [已注释] todowrite schema 宽松化, 对所有模型生效
// 如不需要 todowrite 兼容, 保持注释即可
// "tool.definition": async (input, output) => {
// if (input.toolID !== "todowrite") return
// const j = output as any
// if (!j.jsonSchema) return
// const relaxed = JSON.parse(JSON.stringify(j.jsonSchema)) as Record<string, any>
// const todosSchema = relaxed.properties?.todos
// if (todosSchema) {
// relaxed.properties.todos = {
// description: todosSchema.description ?? "The updated todo list (array or JSON string)",
// anyOf: [todosSchema, { type: "string", description: "JSON-encoded array of todos (will be parsed)" }],
// }
// }
// if (Array.isArray(relaxed.required)) relaxed.required = relaxed.required.filter((r: string) => r !== "priority")
// relaxed.additionalProperties = true
// const items = todosSchema?.items
// if (items && typeof items === "object") {
// if (Array.isArray(items.required)) items.required = items.required.filter((r: string) => r !== "priority")
// items.additionalProperties = true
// }
// j.jsonSchema = relaxed
// },
// 核心 hook 2: 字段名规范化
// 通过 sessionIsComposer Map 反查, 非 composer session 直接 return (零副作用)
// 修复映射表:
// edit: new_string→newString, old_string→oldString, file_path→filePath, replace_all→replaceAll
// read: path→filePath, file_path→filePath, limit/offset string→number
// write: contents→content, file_path→filePath, path→filePath
// todowrite: todos string→array, 剥离 merge/id 字段, 默认 priority
"tool.execute.before": async (input, output) => {
// 非 composer session 直接跳过
if (!sessionIsComposer.get(input.sessionID)) return
const a = output.args as Record<string, any>
// invalid tool 日志: composer 调用了不存在的工具名 (别名未启用时可能出现)
if (input.tool === "invalid" && typeof a.tool === "string") {
await log("warn", "composer still hit invalid tool (alias not used or unknown name)", {
sessionID: input.sessionID, callID: input.callID,
attemptedTool: a.tool, error: typeof a.error === "string" ? a.error.slice(0, 240) : a.error,
})
return
}
if (!["edit", "read", "write", "todowrite"].includes(input.tool)) return
// 字段映射 helper: 将 snake_case 键重命名为 camelCase
const remap = (from: string, to: string, mapped: string[]) => {
if (from in a) { a[to] = a[from]; delete a[from]; mapped.push(`${from}→${to}`) }
}
const before = { ...a } // 快照原始 args, 用于审计日志
const mapped: string[] = []
if (input.tool === "edit") {
remap("new_string", "newString", mapped)
remap("old_string", "oldString", mapped)
remap("file_path", "filePath", mapped)
remap("replace_all", "replaceAll", mapped)
}
if (input.tool === "read") {
remap("path", "filePath", mapped)
remap("file_path", "filePath", mapped)
// limit/offset 类型修正: string → number (带安全检查)
if (typeof a.limit === "string" && Number.isFinite(Number(a.limit))) {
a.limit = Number(a.limit); mapped.push("limit:string→number")
}
if (typeof a.offset === "string" && Number.isFinite(Number(a.offset))) {
a.offset = Number(a.offset); mapped.push("offset:string→number")
}
}
if (input.tool === "write") {
remap("contents", "content", mapped)
remap("file_path", "filePath", mapped)
remap("path", "filePath", mapped)
}
if (input.tool === "todowrite") {
// composer 有时将 todos 序列化为 JSON 字符串而非数组
if (typeof a.todos === "string") {
try {
const parsed = JSON.parse(a.todos.trim())
if (Array.isArray(parsed)) { a.todos = parsed; mapped.push("todos:string→array") }
} catch { /* 保留原样, 由 schema 报错 */ }
}
// 剥离 composer 特有的多余字段
if ("merge" in a) { delete a.merge; mapped.push("merge:stripped") }
if (Array.isArray(a.todos)) {
for (const item of a.todos) {
if (item && typeof item === "object") {
if ("id" in item) { delete item.id; mapped.push("todo.id:stripped") }
if (!("priority" in item)) { item.priority = "medium"; mapped.push("todo.priority:defaulted") }
}
}
}
}
// 只在有实际映射时记日志, 避免噪音
if (mapped.length > 0) {
await log("info", "tool args normalized", {
sessionID: input.sessionID, tool: input.tool, callID: input.callID, mapped, before,
})
}
},
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment