feat(hooks): add json-error-recovery hook to prevent infinite retry loops

This commit is contained in:
bowtiedswan
2026-01-13 10:02:21 +02:00
parent 6208c07809
commit 86f2a93fc9
7 changed files with 121 additions and 0 deletions

View File

@@ -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",

View File

@@ -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";

View File

@@ -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}`
}
},
}
}

View File

@@ -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<typeof createJsonErrorRecoveryHook>
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")
})
})
})

View File

@@ -0,0 +1,5 @@
export {
createJsonErrorRecoveryHook,
JSON_ERROR_PATTERNS,
JSON_ERROR_REMINDER,
} from "./hook"

View File

@@ -13,6 +13,7 @@ import {
createInteractiveBashSessionHook,
createRalphLoopHook,
createEditErrorRecoveryHook,
createJsonErrorRecoveryHook,
createDelegateTaskRetryHook,
createTaskResumeInfoHook,
createStartWorkHook,
@@ -43,6 +44,7 @@ export type SessionHooks = {
interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null
ralphLoop: ReturnType<typeof createRalphLoopHook> | null
editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null
startWork: ReturnType<typeof createStartWorkHook> | null
prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | 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,

View File

@@ -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)