feat(hooks): add json-error-recovery hook to prevent infinite retry loops
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
41
src/hooks/json-error-recovery/hook.ts
Normal file
41
src/hooks/json-error-recovery/hook.ts
Normal 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}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
65
src/hooks/json-error-recovery/index.test.ts
Normal file
65
src/hooks/json-error-recovery/index.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
5
src/hooks/json-error-recovery/index.ts
Normal file
5
src/hooks/json-error-recovery/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createJsonErrorRecoveryHook,
|
||||
JSON_ERROR_PATTERNS,
|
||||
JSON_ERROR_REMINDER,
|
||||
} from "./hook"
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user