Skip to content

fix(hooks): preserve non-text parts in fromHookLLMRequest#26275

Merged
SandyTao520 merged 2 commits into
mainfrom
fix/hook-translator-preserve-non-text-parts-25558
May 4, 2026
Merged

fix(hooks): preserve non-text parts in fromHookLLMRequest#26275
SandyTao520 merged 2 commits into
mainfrom
fix/hook-translator-preserve-non-text-parts-25558

Conversation

@SandyTao520

Copy link
Copy Markdown
Contributor

Summary

Fixes a bug where BeforeModel hooks that modify text in conversations
containing tool calls cause the model to enter an infinite tool-call loop.

HookTranslatorGenAIv1.fromHookLLMRequest rebuilt the SDK request's
contents text-only from hookRequest.messages, discarding every non-text
part (functionCall, functionResponse, inlineData, thought, etc.) from
baseRequest. The hook stripped tool call/response history, the model lost
its prior tool context, and it re-invoked the same tool.

This blocks any text-modifying hook (PII redaction for FERPA/HIPAA, prompt
rewriting, etc.) the moment a tool call enters the conversation — which is
essentially always for an agent.

Details

fromHookLLMRequest now merges hook text edits back into baseRequest.contents
in place instead of rebuilding contents wholesale:

  • Walks baseContents in order, consuming hook messages by index.
  • For entries that originally contributed text (i.e. produced a hook message),
    replaces text parts with the hook's edited text and preserves all non-text
    parts in their original positions.
  • For entries that were skipped by toHookLLMRequest (no text parts), passes
    them through unmodified.
  • Appends any extra hook messages (new turns added by the hook) at the end.
  • Falls back to text-only behavior when baseRequest is absent or has no
    contents (preserves existing semantics).
  • Preserves the existing model-only-override fallback (when
    hookRequest.messages is undefined) added in fix(core): handle partial llm_request in BeforeModel hook override #22326.
  • Tightens isContentWithParts to narrow to Content so the implementation
    has zero unsafe casts and no eslint suppressions.

Implementation and the six new regression tests are adapted from the
contributor PR #23340, which was auto-closed under the 14-day no-help wanted
policy (not on technical merit — Gemini Code Assist's own review on that PR:
"The implementation is robust... I found no issues."). Credit to
@mk-imagine for the original diagnosis, algorithm, and tests.

Related Issues

Fixes #25558
Supersedes #23340

How to Validate

  1. Reproduce the bug on main — write a BeforeModel hook that modifies
    text (e.g. PII redaction), start a conversation that triggers a tool call,
    then submit a follow-up prompt. Observe the model re-invoking the same tool
    in a loop because prior tool context is gone.

  2. Run the new regression tests (which fail on main):

    npm test -w @google/gemini-cli-core -- src/hooks/hookTranslator.test.ts

    All 18 tests should pass — 12 pre-existing plus 6 new under
    fromHookLLMRequest with baseRequest (non-text part preservation):

    • preserve functionCall parts when merging hook text back
    • handle text-only entries interleaved with function-only entries
    • collapse multiple text parts and preserve non-text parts
    • fall back to text-only when baseRequest is undefined
    • fall back to text-only when baseRequest has no contents
    • append extra hook messages beyond base contents
  3. Confirm no regression in adjacent hook code:

    npm test -w @google/gemini-cli-core -- src/hooks/

    All 155 tests across packages/core/src/hooks/ should pass.

  4. Typecheck and lint clean on the touched files:

    npx tsc --noEmit -p packages/core/tsconfig.json
    npx eslint packages/core/src/hooks/hookTranslator.ts packages/core/src/hooks/hookTranslator.test.ts

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed)
  • Added/updated tests (if needed)
  • Noted breaking changes (if any) — none; behavior is additive (preserves
    data that was previously dropped). Hooks that were already round-tripping
    successfully (text-only conversations, or hooks that returned {}) are
    unaffected.
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

Before this change, fromHookLLMRequest rebuilt the SDK request's contents
text-only from hookRequest.messages, discarding every non-text part
(functionCall, functionResponse, inlineData, thought, etc.) from
baseRequest. This broke any BeforeModel hook that modified text in
conversations containing tool calls: the hook stripped tool call/response
history, the model lost its prior tool context, and it re-invoked the
same tool — producing an infinite tool-call loop.

Fix it by merging hook text edits back into baseRequest.contents in
place. Walk baseContents in order, consume hook messages by index,
replace text parts with the hook's edited text, and preserve all
non-text parts in their original positions. Entries that contributed no
text to the hook view (e.g. a content with only a functionResponse) are
preserved untouched. Extra hook messages beyond baseContents are
appended as new turns.

Falls back to the existing text-only behavior when baseRequest is absent
or has no contents, and preserves the existing model-only-override
fallback (when hookRequest.messages is undefined) added in #22326.

Implementation and the six new regression tests are adapted from the
contributor PR #23340 (auto-closed under the 14-day no-help-wanted
policy, not on technical merit).

Fixes #25558
@SandyTao520 SandyTao520 requested a review from a team as a code owner April 30, 2026 17:55
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug where text-modifying hooks inadvertently destroyed essential tool-call and response context in LLM conversations. By refactoring the request translation logic to merge edits in place, the system now preserves non-text components, ensuring model continuity and preventing infinite tool-call loops. The changes also include improved type safety and comprehensive regression testing.

Highlights

  • Bug Fix: Resolved an issue where BeforeModel hooks caused infinite tool-call loops by incorrectly stripping non-text parts (like function calls) from conversation history.
  • Implementation Change: Updated fromHookLLMRequest to merge hook text edits back into the original baseRequest.contents in place, rather than rebuilding the contents list from scratch.
  • Testing: Added six new regression tests to ensure non-text parts are correctly preserved during text-modifying hook operations.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue #25558 by updating the fromHookLLMRequest method to merge hook-modified text back into the original baseRequest.contents. This ensures that non-text parts, such as functionCall and functionResponse, are preserved during the translation process. The changes include a more robust merging logic and a comprehensive suite of regression tests. Feedback was provided regarding a potential logic discrepancy in how the translator identifies which content entries contributed to the hook view, specifically noting that empty text parts might cause the message index to become desynchronized.

Comment on lines +294 to +303
const parts: Part[] = content.parts ?? [];
const hasText = parts.some(hasTextProperty);
const baseContent: Content = { ...content, parts };

if (!hasText) {
// toHookLLMRequest skipped this entry — preserve it untouched so
// tool-call/response history is not lost.
merged.push(baseContent);
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for determining if a content entry contributed a message to the hook view should exactly match the logic in toHookLLMRequest. Currently, toHookLLMRequest only emits a message if the joined text content is non-empty (see lines 191-197). However, fromHookLLMRequest uses parts.some(hasTextProperty), which returns true even if the text parts are empty strings (e.g., [{ text: '' }]). This discrepancy will cause messageIndex to get out of sync if the conversation contains empty text parts.

Additionally, to be consistent with the defensive handling in toHookLLMRequest (lines 186-188), we should ensure parts is treated as an array to avoid potential runtime errors if the SDK returns a single part object instead of an array.

        const parts = Array.isArray(content.parts)
          ? content.parts
          : content.parts
            ? [content.parts]
            : [];
        const textContent = parts
          .filter(hasTextProperty)
          .map((part) => part.text)
          .join('');
        const baseContent: Content = { ...content, parts };

        if (!textContent) {
          // toHookLLMRequest skipped this entry — preserve it untouched so
          // tool-call/response history is not lost.
          merged.push(baseContent);
          continue;
        }

@github-actions

github-actions Bot commented Apr 30, 2026

Copy link
Copy Markdown

Size Change: +2.04 kB (+0.01%)

Total Size: 33.9 MB

Filename Size Change
./bundle/chunk-236ONPCG.js 0 B -12.5 kB (removed) 🏆
./bundle/chunk-4EILQ74Z.js 0 B -19.5 kB (removed) 🏆
./bundle/chunk-64XHI5ON.js 0 B -3.8 kB (removed) 🏆
./bundle/chunk-A3DTGHGI.js 0 B -657 kB (removed) 🏆
./bundle/chunk-FFPFMQ76.js 0 B -49.2 kB (removed) 🏆
./bundle/chunk-G4FLWYLL.js 0 B -2.72 MB (removed) 🏆
./bundle/chunk-SXXFYGHX.js 0 B -14.7 MB (removed) 🏆
./bundle/chunk-YNIVRW3V.js 0 B -3.43 kB (removed) 🏆
./bundle/core-EMNXNZTC.js 0 B -48.4 kB (removed) 🏆
./bundle/devtoolsService-AGZHIDMZ.js 0 B -28 kB (removed) 🏆
./bundle/gemini-VEXUSHPP.js 0 B -582 kB (removed) 🏆
./bundle/interactiveCli-BLQ7V3QW.js 0 B -1.32 MB (removed) 🏆
./bundle/liteRtServerManager-DQ2ASCG3.js 0 B -2.11 kB (removed) 🏆
./bundle/oauth2-provider-R7BAVZ3M.js 0 B -9.16 kB (removed) 🏆
./bundle/chunk-65V4HMFB.js 2.72 MB +2.72 MB (new file) 🆕
./bundle/chunk-6ER64R37.js 12.5 kB +12.5 kB (new file) 🆕
./bundle/chunk-D7ER4PEK.js 3.43 kB +3.43 kB (new file) 🆕
./bundle/chunk-MFKXWWFF.js 657 kB +657 kB (new file) 🆕
./bundle/chunk-NFHF2UQU.js 14.7 MB +14.7 MB (new file) 🆕
./bundle/chunk-YATXM4ET.js 3.8 kB +3.8 kB (new file) 🆕
./bundle/chunk-ZE3LKA4X.js 49.2 kB +49.2 kB (new file) 🆕
./bundle/chunk-ZMGU7ZBX.js 19.5 kB +19.5 kB (new file) 🆕
./bundle/core-YJK3KFCQ.js 48.4 kB +48.4 kB (new file) 🆕
./bundle/devtoolsService-3HWASAPV.js 28 kB +28 kB (new file) 🆕
./bundle/gemini-MULQ76ZM.js 582 kB +582 kB (new file) 🆕
./bundle/interactiveCli-BGZYOA2O.js 1.32 MB +1.32 MB (new file) 🆕
./bundle/liteRtServerManager-EIOEIANH.js 2.11 kB +2.11 kB (new file) 🆕
./bundle/oauth2-provider-MAOOKKZS.js 9.16 kB +9.16 kB (new file) 🆕
ℹ️ View Unchanged
Filename Size Change
./bundle/bundled/third_party/index.js 8 MB 0 B
./bundle/chunk-34MYV7JD.js 2.45 kB 0 B
./bundle/chunk-5AUYMPVF.js 858 B 0 B
./bundle/chunk-5PS3AYFU.js 1.18 kB 0 B
./bundle/chunk-664ZODQF.js 124 kB 0 B
./bundle/chunk-DAHVX5MI.js 206 kB 0 B
./bundle/chunk-DD4MWEAB.js 1.97 MB 0 B
./bundle/chunk-IUUIT4SU.js 56.5 kB 0 B
./bundle/chunk-RJTRUG2J.js 39.8 kB 0 B
./bundle/cleanup-Y6R3I7CJ.js 0 B -932 B (removed) 🏆
./bundle/devtools-36NN55EP.js 696 kB 0 B
./bundle/dist-T73EYRDX.js 356 B 0 B
./bundle/events-XB7DADIJ.js 418 B 0 B
./bundle/examples/hooks/scripts/on-start.js 188 B 0 B
./bundle/examples/mcp-server/example.js 1.43 kB 0 B
./bundle/gemini.js 5.1 kB 0 B
./bundle/getMachineId-bsd-TXG52NKR.js 1.55 kB 0 B
./bundle/getMachineId-darwin-7OE4DDZ6.js 1.55 kB 0 B
./bundle/getMachineId-linux-SHIFKOOX.js 1.34 kB 0 B
./bundle/getMachineId-unsupported-5U5DOEYY.js 1.06 kB 0 B
./bundle/getMachineId-win-6KLLGOI4.js 1.72 kB 0 B
./bundle/memoryDiscovery-HRURE3F3.js 980 B 0 B
./bundle/multipart-parser-KPBZEGQU.js 11.7 kB 0 B
./bundle/node_modules/@google/gemini-cli-devtools/dist/client/main.js 222 kB 0 B
./bundle/node_modules/@google/gemini-cli-devtools/dist/src/_client-assets.js 229 kB 0 B
./bundle/node_modules/@google/gemini-cli-devtools/dist/src/index.js 13.4 kB 0 B
./bundle/node_modules/@google/gemini-cli-devtools/dist/src/types.js 132 B 0 B
./bundle/sandbox-macos-permissive-open.sb 890 B 0 B
./bundle/sandbox-macos-permissive-proxied.sb 1.31 kB 0 B
./bundle/sandbox-macos-restrictive-open.sb 3.36 kB 0 B
./bundle/sandbox-macos-restrictive-proxied.sb 3.56 kB 0 B
./bundle/sandbox-macos-strict-open.sb 4.82 kB 0 B
./bundle/sandbox-macos-strict-proxied.sb 5.02 kB 0 B
./bundle/src-QVCVGIUX.js 47 kB 0 B
./bundle/start-HDRWHW6K.js 0 B -652 B (removed) 🏆
./bundle/tree-sitter-7U6MW5PS.js 274 kB 0 B
./bundle/tree-sitter-bash-34ZGLXVX.js 1.84 MB 0 B
./bundle/cleanup-BF2IA2TO.js 932 B +932 B (new file) 🆕
./bundle/start-XW7GMVHU.js 652 B +652 B (new file) 🆕

compressed-size-action

@gemini-cli gemini-cli Bot added area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! labels Apr 30, 2026
Bojun-Vvibe added a commit to Bojun-Vvibe/oss-contributions that referenced this pull request Apr 30, 2026
… #26275

Second batch of drip-213 reviews:
- openai/codex#20465 derive PermissionProfile directly in test helper (merge-as-is)
- BerriAI/litellm#26895 preserve aiohttp raw response headers for UTF-8 (merge-as-is)
- BerriAI/litellm#26893 cache-read pricing in custom-cost path (merge-after-nits)
- google-gemini/gemini-cli#26275 preserve non-text parts through hook translator (merge-after-nits)

@akh64bit akh64bit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@SandyTao520 SandyTao520 added this pull request to the merge queue May 4, 2026
Merged via the queue into main with commit 165efa8 May 4, 2026
27 checks passed
@SandyTao520 SandyTao520 deleted the fix/hook-translator-preserve-non-text-parts-25558 branch May 4, 2026 17:59
TirthNaik-99 pushed a commit to TirthNaik-99/gemini-cli that referenced this pull request May 4, 2026
kimjune01 pushed a commit to kimjune01/gemini-cli-claude that referenced this pull request May 6, 2026
@sripasg sripasg added the size/l A large sized PR label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! size/l A large sized PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BeforeModel hook with llm_request modification destroys non-text parts, causing tool-call loops

3 participants