diff --git a/src/config/schema.ts b/src/config/schema.ts index dba799cb80..66c34ab960 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -84,6 +84,7 @@ export const HookNameSchema = z.enum([ "claude-code-hooks", "auto-slash-command", "edit-error-recovery", + "json-error-recovery", "prometheus-md-only", "start-work", "sisyphus-orchestrator", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 642872e909..b34d2963b3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -26,6 +26,7 @@ export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; +export { createJsonErrorRecoveryHook } from "./json-error-recovery"; export { createPrometheusMdOnlyHook } from "./prometheus-md-only"; export { createTaskResumeInfoHook } from "./task-resume-info"; export { createStartWorkHook } from "./start-work"; diff --git a/src/hooks/json-error-recovery/index.ts b/src/hooks/json-error-recovery/index.ts new file mode 100644 index 0000000000..1428860ba6 --- /dev/null +++ b/src/hooks/json-error-recovery/index.ts @@ -0,0 +1,47 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +/** + * System reminder injected when a tool call fails due to JSON parse error. + * Forces the agent to stop and correct its output format. + */ +export const JSON_ERROR_REMINDER = ` +[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED] + +You sent invalid JSON arguments. The system could not parse your tool call. +STOP and do this NOW: + +1. LOOK at the error message above to see what was expected vs what you sent. +2. CORRECT your JSON syntax (missing braces, unescaped quotes, trailing commas, etc). +3. RETRY the tool call with valid JSON. + +DO NOT repeat the exact same invalid call. +` + +/** + * Detects JSON parse errors in tool outputs and injects a recovery reminder. + * + * Catches errors like: + * - JSON Parse error: Expected '}' + * - JSON Parse error: Unexpected EOF + * - SyntaxError: Unexpected token + */ +export function createJsonErrorRecoveryHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + // Check for common JSON error patterns in the output + const outputLower = output.output.toLowerCase() + const isJsonError = + outputLower.includes("json parse error") || + outputLower.includes("syntaxerror: unexpected token") || + outputLower.includes("expected '}'") || + outputLower.includes("unexpected eof") + + if (isJsonError) { + output.output += `\n${JSON_ERROR_REMINDER}` + } + }, + } +} diff --git a/src/index.ts b/src/index.ts index 4380e1a879..c24c3f32cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,9 @@ import { createRalphLoopHook, createAutoSlashCommandHook, createEditErrorRecoveryHook, + createJsonErrorRecoveryHook, createTaskResumeInfoHook, + createStartWorkHook, createSisyphusOrchestratorHook, createPrometheusMdOnlyHook, @@ -202,7 +204,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createEditErrorRecoveryHook(ctx) : null; + const jsonErrorRecovery = isHookEnabled("json-error-recovery") + ? createJsonErrorRecoveryHook(ctx) + : null; + const startWork = isHookEnabled("start-work") + ? createStartWorkHook(ctx) : null; @@ -555,7 +562,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await agentUsageReminder?.["tool.execute.after"](input, output); await interactiveBashSession?.["tool.execute.after"](input, output); await editErrorRecovery?.["tool.execute.after"](input, output); + await jsonErrorRecovery?.["tool.execute.after"](input, output); await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output); + await taskResumeInfo["tool.execute.after"](input, output); }, }; diff --git a/tests/edit_recovery.test.ts b/tests/edit_recovery.test.ts new file mode 100644 index 0000000000..4abb4e4f00 --- /dev/null +++ b/tests/edit_recovery.test.ts @@ -0,0 +1,44 @@ + +import { describe, it, expect, vi } from "vitest" +import { createEditErrorRecoveryHook } from "../src/hooks/edit-error-recovery" +import { EDIT_ERROR_REMINDER } from "../src/hooks/edit-error-recovery" + +// Mock PluginInput +const mockCtx: any = { + client: {}, + directory: "/tmp", +} + +describe("Edit Error Recovery Hook", () => { + it("should inject reminder when edit error occurs", async () => { + const hook = createEditErrorRecoveryHook(mockCtx) + const output = { + title: "Edit", + output: "Error: oldString not found in content", + metadata: {}, + } + + await hook["tool.execute.after"]( + { tool: "Edit", sessionID: "test", callID: "call_1" }, + output + ) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + }) + + it("should not inject reminder for successful edits", async () => { + const hook = createEditErrorRecoveryHook(mockCtx) + const output = { + title: "Edit", + output: "Successfully edited file", + metadata: {}, + } + + await hook["tool.execute.after"]( + { tool: "Edit", sessionID: "test", callID: "call_1" }, + output + ) + + expect(output.output).not.toContain(EDIT_ERROR_REMINDER) + }) +}) diff --git a/tests/json_recovery.test.ts b/tests/json_recovery.test.ts new file mode 100644 index 0000000000..339c878783 --- /dev/null +++ b/tests/json_recovery.test.ts @@ -0,0 +1,59 @@ + +import { describe, it, expect } from "vitest" +import { createJsonErrorRecoveryHook, JSON_ERROR_REMINDER } from "../src/hooks/json-error-recovery" + +// Test context mock +const mockCtx: any = { + client: {}, + directory: "/tmp", +} + +describe("JSON Error Recovery Hook", () => { + it("should inject reminder when json parse error occurs", async () => { + const hook = createJsonErrorRecoveryHook(mockCtx) + const output = { + title: "Tool Error", + output: "JSON Parse error: Expected '}'", + metadata: {}, + } + + await hook["tool.execute.after"]( + { tool: "AnyTool", sessionID: "test", callID: "call_1" }, + output + ) + + expect(output.output).toContain(JSON_ERROR_REMINDER) + }) + + it("should inject reminder for SyntaxError", async () => { + const hook = createJsonErrorRecoveryHook(mockCtx) + const output = { + title: "Tool Error", + output: "SyntaxError: Unexpected token", + metadata: {}, + } + + await hook["tool.execute.after"]( + { tool: "AnyTool", sessionID: "test", callID: "call_1" }, + output + ) + + expect(output.output).toContain(JSON_ERROR_REMINDER) + }) + + it("should not inject reminder for normal output", async () => { + const hook = createJsonErrorRecoveryHook(mockCtx) + const output = { + title: "Success", + output: "Task completed successfully", + metadata: {}, + } + + await hook["tool.execute.after"]( + { tool: "AnyTool", sessionID: "test", callID: "call_1" }, + output + ) + + expect(output.output).not.toContain(JSON_ERROR_REMINDER) + }) +})