[FEATURE]: New Plugin Hook assistant.output.beforeParse #2235

Open
opened 2026-02-16 17:34:47 -05:00 by yindo · 1 comment
Owner

Originally created by @webboty on GitHub (Oct 22, 2025).

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Feature Request: assistant.output.beforeParse Plugin Hook

Goal
Allow plugins to normalize/transform the assistant’s raw text before OpenCode parses <tool_call>…</tool_call> blocks. This enables lightweight model-specific fixes (e.g., inserting a newline after a function name) without forking OpenCode or adding external proxies.

Why
Some models (e.g., GLM-4.6) occasionally emit tool calls like:

<tool_call>write <arg_key>filePath</arg_key> ...

Parsers typically expect:

<tool_call>write
<arg_key>filePath</arg_key>
...

Today, plugins can’t intercept the raw text pre-parse. Existing hooks (event, tool.execute.before/after) happen after parsing—too late to fix formatting.

Proposed Hook

  • Name: assistant.output.beforeParse

  • Timing: After the model finishes a turn (final text available), before OpenCode parses tool calls.

  • Signature (TypeScript):

    type AssistantOutputBeforeParseArgs = {
      modelId: string;           // provider’s model id (e.g., "mlx-community/glm-4.6")
      text: string;              // raw assistant text, exactly as generated
      metadata?: Record<string, any>; // optional provider/runtime metadata
      replaceText: (next: string) => void; // call to replace the raw text
      context: {
        conversationId: string;
        messageId: string;
        project?: string;
      };
    };
    
    // Plugin surface:
    assistant: {
      output: {
        beforeParse?: (args: AssistantOutputBeforeParseArgs) => Promise<void> | void;
      }
    }
    
  • Behavior:

    • If replaceText() is not called, OpenCode proceeds with args.text.
    • If called, OpenCode uses the replacement for downstream parsing.
    • Multiple plugins: run in registration order; each sees the latest text.
    • Errors in a plugin should be caught and logged without aborting the run (fail-open).

Security/Perf Considerations

  • The hook runs synchronously in-process; plugin authors must keep work O(n) on text.
  • Provide a maxChars safeguard or document a soft limit (e.g., 256k) equal to message cap.
  • Log the plugin name and time spent for observability.

Minimal Example Behavior
Normalize same-line function names in <tool_call> blocks by inserting a newline before the first <arg_key> when needed.


Example Plugin (will work once the hook exists)

Place at .opencode/plugin/glm-normalizer.ts (or global: ~/.config/opencode/plugin/glm-normalizer.ts).

// .opencode/plugin/glm-normalizer.ts
export const GLMNormalizer = async () => {
  // Regexes:
  // 1) Find each <tool_call>...</tool_call> block
  const TOOL = /<tool_call>([\s\S]*?)<\/tool_call>/g;
  // 2) Find the FIRST <arg_key> inside a block
  const FIRST_ARG = /<arg_key>/;

  function normalizeBlock(blockInner: string): string {
    // Ensure function name is on its own line:
    // If the first "<arg_key>" appears on the same line as the function name,
    // insert a newline before it.
    const idx = blockInner.indexOf('<arg_key>');
    if (idx <= 0) return blockInner;

    // Everything up to the first arg_key
    const head = blockInner.slice(0, idx);
    // If head already contains a newline, we’re fine
    if (/\r?\n\s*$/.test(head)) return blockInner;

    // Otherwise, insert a newline before the first arg_key
    return head + '\n' + blockInner.slice(idx);
  }

  function normalizeAll(text: string): string {
    return text.replace(TOOL, (_match, inner) => {
      const fixed = normalizeBlock(inner);
      return `<tool_call>${fixed}</tool_call>`;
    });
  }

  return {
    assistant: {
      output: {
        // NEW HOOK — requires platform support
        beforeParse: async ({ text, replaceText }) => {
          // NOTE: You can scope by modelId if desired,
          // e.g., if (modelId.includes("glm-4.6")) { ... }
          const fixed = normalizeAll(text);
          if (fixed !== text) replaceText(fixed);
        },
      },
    },
  };
};

What it does

  • Scans each <tool_call>…</tool_call> block.
  • If <arg_key> comes immediately after the function name on the same line, it inserts a newline.
  • Leaves everything else untouched.
  • Zero network calls; O(n) on the string.
Originally created by @webboty on GitHub (Oct 22, 2025). ### Feature hasn't been suggested before. - [x] I have verified this feature I'm about to request hasn't been suggested before. ### Describe the enhancement you want to request # Feature Request: `assistant.output.beforeParse` Plugin Hook **Goal** Allow plugins to normalize/transform the assistant’s raw text **before** OpenCode parses `<tool_call>…</tool_call>` blocks. This enables lightweight model-specific fixes (e.g., inserting a newline after a function name) without forking OpenCode or adding external proxies. **Why** Some models (e.g., GLM-4.6) occasionally emit tool calls like: ``` <tool_call>write <arg_key>filePath</arg_key> ... ``` Parsers typically expect: ``` <tool_call>write <arg_key>filePath</arg_key> ... ``` Today, plugins can’t intercept the raw text pre-parse. Existing hooks (`event`, `tool.execute.before/after`) happen **after** parsing—too late to fix formatting. **Proposed Hook** * **Name:** `assistant.output.beforeParse` * **Timing:** After the model finishes a turn (final text available), **before** OpenCode parses tool calls. * **Signature (TypeScript):** ```ts type AssistantOutputBeforeParseArgs = { modelId: string; // provider’s model id (e.g., "mlx-community/glm-4.6") text: string; // raw assistant text, exactly as generated metadata?: Record<string, any>; // optional provider/runtime metadata replaceText: (next: string) => void; // call to replace the raw text context: { conversationId: string; messageId: string; project?: string; }; }; // Plugin surface: assistant: { output: { beforeParse?: (args: AssistantOutputBeforeParseArgs) => Promise<void> | void; } } ``` * **Behavior:** * If `replaceText()` is not called, OpenCode proceeds with `args.text`. * If called, OpenCode uses the replacement for downstream parsing. * Multiple plugins: run in registration order; each sees the latest text. * Errors in a plugin should be caught and logged without aborting the run (fail-open). **Security/Perf Considerations** * The hook runs synchronously in-process; plugin authors must keep work O(n) on `text`. * Provide a `maxChars` safeguard or document a soft limit (e.g., 256k) equal to message cap. * Log the plugin name and time spent for observability. **Minimal Example Behavior** Normalize same-line function names in `<tool_call>` blocks by inserting a newline before the first `<arg_key>` when needed. --- # Example Plugin (will work once the hook exists) Place at `.opencode/plugin/glm-normalizer.ts` (or global: `~/.config/opencode/plugin/glm-normalizer.ts`). ```ts // .opencode/plugin/glm-normalizer.ts export const GLMNormalizer = async () => { // Regexes: // 1) Find each <tool_call>...</tool_call> block const TOOL = /<tool_call>([\s\S]*?)<\/tool_call>/g; // 2) Find the FIRST <arg_key> inside a block const FIRST_ARG = /<arg_key>/; function normalizeBlock(blockInner: string): string { // Ensure function name is on its own line: // If the first "<arg_key>" appears on the same line as the function name, // insert a newline before it. const idx = blockInner.indexOf('<arg_key>'); if (idx <= 0) return blockInner; // Everything up to the first arg_key const head = blockInner.slice(0, idx); // If head already contains a newline, we’re fine if (/\r?\n\s*$/.test(head)) return blockInner; // Otherwise, insert a newline before the first arg_key return head + '\n' + blockInner.slice(idx); } function normalizeAll(text: string): string { return text.replace(TOOL, (_match, inner) => { const fixed = normalizeBlock(inner); return `<tool_call>${fixed}</tool_call>`; }); } return { assistant: { output: { // NEW HOOK — requires platform support beforeParse: async ({ text, replaceText }) => { // NOTE: You can scope by modelId if desired, // e.g., if (modelId.includes("glm-4.6")) { ... } const fixed = normalizeAll(text); if (fixed !== text) replaceText(fixed); }, }, }, }; }; ``` **What it does** * Scans each `<tool_call>…</tool_call>` block. * If `<arg_key>` comes immediately after the function name on the **same line**, it inserts a newline. * Leaves everything else untouched. * Zero network calls; O(n) on the string.
yindo added the discussion label 2026-02-16 17:34:47 -05:00
Author
Owner

@rekram1-node commented on GitHub (Oct 22, 2025):

Yeah we could prolly do something like onResponse, but i guess you would need to handle every chunk yourself

@rekram1-node commented on GitHub (Oct 22, 2025): Yeah we could prolly do something like onResponse, but i guess you would need to handle every chunk yourself
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#2235