From 86f2a93fc97a97d3619d82ae1b7e57c97f2e22d0 Mon Sep 17 00:00:00 2001 From: bowtiedswan Date: Tue, 13 Jan 2026 10:02:21 +0200 Subject: [PATCH] feat(hooks): add json-error-recovery hook to prevent infinite retry loops --- src/config/schema/hooks.ts | 1 + src/hooks/index.ts | 1 + src/hooks/json-error-recovery/hook.ts | 41 +++++++++++++ src/hooks/json-error-recovery/index.test.ts | 65 +++++++++++++++++++++ src/hooks/json-error-recovery/index.ts | 5 ++ src/plugin/hooks/create-session-hooks.ts | 7 +++ src/plugin/tool-execute-after.ts | 1 + 7 files changed, 121 insertions(+) create mode 100644 src/hooks/json-error-recovery/hook.ts create mode 100644 src/hooks/json-error-recovery/index.test.ts create mode 100644 src/hooks/json-error-recovery/index.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index d0e1e191..b985c3c5 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -33,6 +33,7 @@ export const HookNameSchema = z.enum([ "claude-code-hooks", "auto-slash-command", "edit-error-recovery", + "json-error-recovery", "delegate-task-retry", "prometheus-md-only", "sisyphus-junior-notepad", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index bdc27211..f199ef80 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -29,6 +29,7 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder"; 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 { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad"; export { createTaskResumeInfoHook } from "./task-resume-info"; diff --git a/src/hooks/json-error-recovery/hook.ts b/src/hooks/json-error-recovery/hook.ts new file mode 100644 index 00000000..da55dd0d --- /dev/null +++ b/src/hooks/json-error-recovery/hook.ts @@ -0,0 +1,41 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +export const JSON_ERROR_PATTERNS = [ + "json parse error", + "syntaxerror: unexpected token", + "expected '}'", + "unexpected eof", +] as const + +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. +` + +export function createJsonErrorRecoveryHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + _input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (typeof output.output !== "string") return + + const outputLower = output.output.toLowerCase() + const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) => + outputLower.includes(pattern) + ) + + if (hasJsonError) { + output.output += `\n${JSON_ERROR_REMINDER}` + } + }, + } +} diff --git a/src/hooks/json-error-recovery/index.test.ts b/src/hooks/json-error-recovery/index.test.ts new file mode 100644 index 00000000..f52d39ce --- /dev/null +++ b/src/hooks/json-error-recovery/index.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from "bun:test" + +import { + createJsonErrorRecoveryHook, + JSON_ERROR_PATTERNS, + JSON_ERROR_REMINDER, +} from "./index" + +describe("createJsonErrorRecoveryHook", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createJsonErrorRecoveryHook({} as any) + }) + + describe("tool.execute.after", () => { + const createInput = () => ({ + tool: "Read", + sessionID: "test-session", + callID: "test-call-id", + }) + + const createOutput = (outputText: string) => ({ + title: "Tool Error", + output: outputText, + metadata: {}, + }) + + it("appends reminder when output includes JSON parse error", async () => { + const input = createInput() + const output = createOutput("JSON Parse error: Expected '}'") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(JSON_ERROR_REMINDER) + }) + + it("appends reminder when output includes SyntaxError", async () => { + const input = createInput() + const output = createOutput("SyntaxError: Unexpected token in JSON at position 10") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(JSON_ERROR_REMINDER) + }) + + it("does not append reminder for normal output", async () => { + const input = createInput() + const output = createOutput("Task completed successfully") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toBe("Task completed successfully") + }) + }) + + describe("JSON_ERROR_PATTERNS", () => { + it("contains known parse error patterns", () => { + expect(JSON_ERROR_PATTERNS).toContain("json parse error") + expect(JSON_ERROR_PATTERNS).toContain("syntaxerror: unexpected token") + expect(JSON_ERROR_PATTERNS).toContain("expected '}'") + expect(JSON_ERROR_PATTERNS).toContain("unexpected eof") + }) + }) +}) diff --git a/src/hooks/json-error-recovery/index.ts b/src/hooks/json-error-recovery/index.ts new file mode 100644 index 00000000..af041c29 --- /dev/null +++ b/src/hooks/json-error-recovery/index.ts @@ -0,0 +1,5 @@ +export { + createJsonErrorRecoveryHook, + JSON_ERROR_PATTERNS, + JSON_ERROR_REMINDER, +} from "./hook" diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index d93ec585..a345be02 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -13,6 +13,7 @@ import { createInteractiveBashSessionHook, createRalphLoopHook, createEditErrorRecoveryHook, + createJsonErrorRecoveryHook, createDelegateTaskRetryHook, createTaskResumeInfoHook, createStartWorkHook, @@ -43,6 +44,7 @@ export type SessionHooks = { interactiveBashSession: ReturnType | null ralphLoop: ReturnType | null editErrorRecovery: ReturnType | null + jsonErrorRecovery: ReturnType | null delegateTaskRetry: ReturnType | null startWork: ReturnType | null prometheusMdOnly: ReturnType | null @@ -130,6 +132,10 @@ export function createSessionHooks(args: { ? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx)) : null + const jsonErrorRecovery = isHookEnabled("json-error-recovery") + ? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx)) + : null + const delegateTaskRetry = isHookEnabled("delegate-task-retry") ? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx)) : null @@ -166,6 +172,7 @@ export function createSessionHooks(args: { interactiveBashSession, ralphLoop, editErrorRecovery, + jsonErrorRecovery, delegateTaskRetry, startWork, prometheusMdOnly, diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 31f20f59..0ecfcb99 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -40,6 +40,7 @@ export function createToolExecuteAfterHandler(args: { await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output) await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output) await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output) + await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output) await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)