Created
July 13, 2026 10:48
-
-
Save watert/a54d2aef546fb08e5db9c6e056ca0371 to your computer and use it in GitHub Desktop.
Grok Composer 2.5 Fast — Tool Call Schema Fix (OpenCode Plugin)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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