From 86f2a93fc97a97d3619d82ae1b7e57c97f2e22d0 Mon Sep 17 00:00:00 2001 From: bowtiedswan Date: Tue, 13 Jan 2026 10:02:21 +0200 Subject: [PATCH 1/2] 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 d0e1e1917..b985c3c53 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 bdc27211d..f199ef80c 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 000000000..da55dd0dd --- /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 000000000..f52d39ce0 --- /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 000000000..af041c291 --- /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 d93ec5853..a345be021 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 31f20f593..0ecfcb998 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) From 5f939f900a6233c95d48ded72bbecf697f57cd45 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 15:46:21 +0900 Subject: [PATCH 2/2] fix(hooks): harden json-error-recovery matching and scope --- src/hooks/json-error-recovery/hook.ts | 37 +++-- src/hooks/json-error-recovery/index.test.ts | 153 ++++++++++++++++++-- src/hooks/json-error-recovery/index.ts | 1 + 3 files changed, 170 insertions(+), 21 deletions(-) diff --git a/src/hooks/json-error-recovery/hook.ts b/src/hooks/json-error-recovery/hook.ts index da55dd0dd..418401a10 100644 --- a/src/hooks/json-error-recovery/hook.ts +++ b/src/hooks/json-error-recovery/hook.ts @@ -1,12 +1,30 @@ import type { PluginInput } from "@opencode-ai/plugin" -export const JSON_ERROR_PATTERNS = [ - "json parse error", - "syntaxerror: unexpected token", - "expected '}'", - "unexpected eof", +export const JSON_ERROR_TOOL_EXCLUDE_LIST = [ + "bash", + "read", + "glob", + "grep", + "webfetch", + "look_at", + "grep_app_searchgithub", + "websearch_web_search_exa", ] as const +export const JSON_ERROR_PATTERNS = [ + /json parse error/i, + /failed to parse json/i, + /invalid json/i, + /malformed json/i, + /unexpected end of json input/i, + /syntaxerror:\s*unexpected token.*json/i, + /json[^\n]*expected '\}'/i, + /json[^\n]*unexpected eof/i, +] as const + +const JSON_ERROR_REMINDER_MARKER = "[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]" +const JSON_ERROR_EXCLUDED_TOOLS = new Set(JSON_ERROR_TOOL_EXCLUDE_LIST) + export const JSON_ERROR_REMINDER = ` [JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED] @@ -23,15 +41,14 @@ 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 }, + input: { tool: string; sessionID: string; callID: string }, output: { title: string; output: string; metadata: unknown } ) => { + if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return if (typeof output.output !== "string") return + if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return - const outputLower = output.output.toLowerCase() - const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) => - outputLower.includes(pattern) - ) + const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output.output)) 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 index f52d39ce0..04bcb6d89 100644 --- a/src/hooks/json-error-recovery/index.test.ts +++ b/src/hooks/json-error-recovery/index.test.ts @@ -1,65 +1,196 @@ import { beforeEach, describe, expect, it } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" import { createJsonErrorRecoveryHook, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER, + JSON_ERROR_TOOL_EXCLUDE_LIST, } from "./index" describe("createJsonErrorRecoveryHook", () => { let hook: ReturnType + type ToolExecuteAfterHandler = NonNullable< + ReturnType["tool.execute.after"] + > + type ToolExecuteAfterInput = Parameters[0] + type ToolExecuteAfterOutput = Parameters[1] + + const createMockPluginInput = (): PluginInput => { + return { + client: {} as PluginInput["client"], + directory: "/tmp/test", + } as PluginInput + } + beforeEach(() => { - hook = createJsonErrorRecoveryHook({} as any) + hook = createJsonErrorRecoveryHook(createMockPluginInput()) }) describe("tool.execute.after", () => { - const createInput = () => ({ - tool: "Read", + const createInput = (tool = "Edit"): ToolExecuteAfterInput => ({ + tool, sessionID: "test-session", callID: "test-call-id", }) - const createOutput = (outputText: string) => ({ + const createOutput = (outputText: string): ToolExecuteAfterOutput => ({ 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 '}'") + const createUnknownOutput = (value: unknown): { title: string; output: unknown; metadata: Record } => ({ + title: "Tool Error", + output: value, + metadata: {}, + }) + it("appends reminder when output includes JSON parse error", async () => { + // given + const input = createInput() + const output = createOutput("JSON parse error: expected '}' in JSON body") + + // when await hook["tool.execute.after"](input, output) + // then expect(output.output).toContain(JSON_ERROR_REMINDER) }) it("appends reminder when output includes SyntaxError", async () => { + // given const input = createInput() const output = createOutput("SyntaxError: Unexpected token in JSON at position 10") + // when await hook["tool.execute.after"](input, output) + // then expect(output.output).toContain(JSON_ERROR_REMINDER) }) it("does not append reminder for normal output", async () => { + // given const input = createInput() const output = createOutput("Task completed successfully") + // when await hook["tool.execute.after"](input, output) + // then expect(output.output).toBe("Task completed successfully") }) + + it("does not append reminder for empty output", async () => { + // given + const input = createInput() + const output = createOutput("") + + // when + await hook["tool.execute.after"](input, output) + + // then + expect(output.output).toBe("") + }) + + it("does not append reminder for false positive non-JSON text", async () => { + // given + const input = createInput() + const output = createOutput("Template failed: expected '}' before newline") + + // when + await hook["tool.execute.after"](input, output) + + // then + expect(output.output).toBe("Template failed: expected '}' before newline") + }) + + it("does not append reminder for excluded tools", async () => { + // given + const input = createInput("Read") + const output = createOutput("JSON parse error: unexpected end of JSON input") + + // when + await hook["tool.execute.after"](input, output) + + // then + expect(output.output).toBe("JSON parse error: unexpected end of JSON input") + }) + + it("does not append reminder when reminder already exists", async () => { + // given + const input = createInput() + const output = createOutput(`JSON parse error: invalid JSON\n${JSON_ERROR_REMINDER}`) + + // when + await hook["tool.execute.after"](input, output) + + // then + const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1 + expect(reminderCount).toBe(1) + }) + + it("does not append duplicate reminder on repeated execution", async () => { + // given + const input = createInput() + const output = createOutput("JSON parse error: invalid JSON arguments") + + // when + await hook["tool.execute.after"](input, output) + await hook["tool.execute.after"](input, output) + + // then + const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1 + expect(reminderCount).toBe(1) + }) + + it("ignores non-string output values", async () => { + // given + const input = createInput() + const values: unknown[] = [42, null, undefined, { error: "invalid json" }] + + // when + for (const value of values) { + const output = createUnknownOutput(value) + await hook["tool.execute.after"](input, output as ToolExecuteAfterOutput) + + // then + expect(output.output).toBe(value) + } + }) }) 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") + // given + const output = "JSON parse error: unexpected end of JSON input" + + // when + const isMatched = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output)) + + // then + expect(isMatched).toBe(true) + }) + }) + + describe("JSON_ERROR_TOOL_EXCLUDE_LIST", () => { + it("contains content-heavy tools that should be excluded", () => { + // given + const expectedExcludedTools: Array<(typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]> = [ + "read", + "bash", + "webfetch", + ] + + // when + const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) => + JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName) + ) + + // then + expect(allExpectedToolsIncluded).toBe(true) }) }) }) diff --git a/src/hooks/json-error-recovery/index.ts b/src/hooks/json-error-recovery/index.ts index af041c291..b56649235 100644 --- a/src/hooks/json-error-recovery/index.ts +++ b/src/hooks/json-error-recovery/index.ts @@ -1,5 +1,6 @@ export { createJsonErrorRecoveryHook, + JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER, } from "./hook"