Browse Source

Add json error retry and update nudges (#155)

* feat: add JSON parse error recovery hook

* Update nudges
Alvin 1 month ago
parent
commit
21c34477fb

+ 1 - 0
src/hooks/index.ts

@@ -1,5 +1,6 @@
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
 export { createDelegateTaskRetryHook } from './delegate-task-retry';
+export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostReadNudgeHook } from './post-read-nudge';

+ 74 - 0
src/hooks/json-error-recovery/hook.ts

@@ -0,0 +1,74 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+
+export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
+  'bash',
+  'read',
+  'glob',
+  'grep',
+  'webfetch',
+  '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<string>(JSON_ERROR_TOOL_EXCLUDE_LIST);
+
+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.
+`;
+
+interface ToolExecuteAfterInput {
+  tool: string;
+  sessionID: string;
+  callID: string;
+}
+
+interface ToolExecuteAfterOutput {
+  title: string;
+  output: unknown;
+  metadata: unknown;
+}
+
+export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
+  return {
+    'tool.execute.after': async (
+      input: ToolExecuteAfterInput,
+      output: ToolExecuteAfterOutput,
+    ): Promise<void> => {
+      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 outputText = output.output;
+
+      const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) =>
+        pattern.test(outputText),
+      );
+
+      if (hasJsonError) {
+        output.output += `\n${JSON_ERROR_REMINDER}`;
+      }
+    },
+  };
+}

+ 111 - 0
src/hooks/json-error-recovery/index.test.ts

@@ -0,0 +1,111 @@
+import { beforeEach, describe, expect, test } 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('json-error-recovery hook', () => {
+  let hook: ReturnType<typeof createJsonErrorRecoveryHook>;
+
+  type ToolExecuteAfterHandler = NonNullable<
+    ReturnType<typeof createJsonErrorRecoveryHook>['tool.execute.after']
+  >;
+  type ToolExecuteAfterInput = Parameters<ToolExecuteAfterHandler>[0];
+  type ToolExecuteAfterOutput = Parameters<ToolExecuteAfterHandler>[1];
+
+  const createMockPluginInput = (): PluginInput => {
+    return {
+      client: {} as PluginInput['client'],
+      directory: '/tmp/test',
+    } as PluginInput;
+  };
+
+  beforeEach(() => {
+    hook = createJsonErrorRecoveryHook(createMockPluginInput());
+  });
+
+  const createInput = (tool = 'Edit'): ToolExecuteAfterInput => ({
+    tool,
+    sessionID: 'test-session',
+    callID: 'test-call-id',
+  });
+
+  const createOutput = (outputText: unknown): ToolExecuteAfterOutput => ({
+    title: 'Tool Error',
+    output: outputText,
+    metadata: {},
+  });
+
+  test('appends reminder when output includes JSON parse error', async () => {
+    const output = createOutput("JSON parse error: expected '}' in JSON body");
+
+    await hook['tool.execute.after'](createInput(), output);
+
+    expect(output.output).toContain(JSON_ERROR_REMINDER);
+  });
+
+  test('does not append reminder for normal output', async () => {
+    const output = createOutput('Task completed successfully');
+
+    await hook['tool.execute.after'](createInput(), output);
+
+    expect(output.output).toBe('Task completed successfully');
+  });
+
+  test('does not append reminder for excluded tools', async () => {
+    const output = createOutput(
+      'JSON parse error: unexpected end of JSON input',
+    );
+
+    await hook['tool.execute.after'](createInput('Read'), output);
+
+    expect(output.output).toBe(
+      'JSON parse error: unexpected end of JSON input',
+    );
+  });
+
+  test('does not append duplicate reminder on repeated execution', async () => {
+    const output = createOutput('JSON parse error: invalid JSON arguments');
+
+    await hook['tool.execute.after'](createInput(), output);
+    await hook['tool.execute.after'](createInput(), output);
+
+    const reminderCount =
+      String(output.output).split(
+        '[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]',
+      ).length - 1;
+    expect(reminderCount).toBe(1);
+  });
+
+  test('ignores non-string output values', async () => {
+    const values: unknown[] = [42, null, undefined, { error: 'invalid json' }];
+
+    for (const value of values) {
+      const output = createOutput(value);
+      await hook['tool.execute.after'](createInput(), output);
+      expect(output.output).toBe(value);
+    }
+  });
+
+  test('pattern list detects known JSON parse errors', () => {
+    const output = 'JSON parse error: unexpected end of JSON input';
+    const isMatched = JSON_ERROR_PATTERNS.some((pattern) =>
+      pattern.test(output),
+    );
+    expect(isMatched).toBe(true);
+  });
+
+  test('exclude list contains content-heavy tools', () => {
+    const expectedExcludedTools: Array<
+      (typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]
+    > = ['read', 'bash', 'webfetch'];
+
+    const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) =>
+      JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName),
+    );
+    expect(allExpectedToolsIncluded).toBe(true);
+  });
+});

+ 6 - 0
src/hooks/json-error-recovery/index.ts

@@ -0,0 +1,6 @@
+export {
+  createJsonErrorRecoveryHook,
+  JSON_ERROR_PATTERNS,
+  JSON_ERROR_REMINDER,
+  JSON_ERROR_TOOL_EXCLUDE_LIST,
+} from './hook';

+ 3 - 3
src/hooks/phase-reminder/index.ts

@@ -8,9 +8,9 @@
  *
  * Uses experimental.chat.messages.transform so it doesn't show in UI.
  */
-const PHASE_REMINDER = `<reminder>⚠️ MANDATORY: Understand→DELEGATE(! based on each agent rules)→Split-and-Parallelize(?)→Plan→Execute→Verify
-Available Specialist: @oracle @librarian @explorer @designer @fixer
-</reminder>`;
+const PHASE_REMINDER = `<reminder>Recall Workflow Rules:
+Understand → find the best path (delegate based on rules and parallelize independent work) → execute → verify.
+If delegating, launch the specialist in the same turn you mention it.</reminder>`;
 
 interface MessageInfo {
   role: string;

+ 1 - 1
src/hooks/post-read-nudge/index.ts

@@ -4,7 +4,7 @@
  */
 
 const NUDGE =
-  '\n\n---\nReminder to follow the workflow instructions, consider delegation to specialist(s)';
+  '\n\n---\nWorkflow Reminder: delegate based on rules; If mentioning a specialist, launch it in this same turn.';
 
 interface ToolExecuteAfterInput {
   tool: string;

+ 17 - 0
src/index.ts

@@ -6,6 +6,7 @@ import { parseList } from './config/agent-mcps';
 import {
   createAutoUpdateCheckerHook,
   createDelegateTaskRetryHook,
+  createJsonErrorRecoveryHook,
   createPhaseReminderHook,
   createPostReadNudgeHook,
 } from './hooks';
@@ -72,6 +73,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   // Initialize delegate-task retry guidance hook
   const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
 
+  // Initialize JSON parse error recovery hook
+  const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
+
   return {
     name: 'oh-my-opencode-slim',
 
@@ -212,6 +216,19 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output as { output: unknown },
       );
 
+      await jsonErrorRecoveryHook['tool.execute.after'](
+        input as {
+          tool: string;
+          sessionID: string;
+          callID: string;
+        },
+        output as {
+          title: string;
+          output: unknown;
+          metadata: unknown;
+        },
+      );
+
       await postReadNudgeHook['tool.execute.after'](
         input as {
           tool: string;