Skip to content

fix(core): limit recursive reasoning turns per single user request#28164

Open
amelidev wants to merge 18 commits into
google-gemini:mainfrom
amelidev:b_522410012
Open

fix(core): limit recursive reasoning turns per single user request#28164
amelidev wants to merge 18 commits into
google-gemini:mainfrom
amelidev:b_522410012

Conversation

@amelidev

Copy link
Copy Markdown
Contributor

Summary

This PR implements a strict recursive reasoning turn limit of 15 turns per single user request (customizable if maxSessionTurns is configured to a positive value) in the core agent reasoning engine. This protects user local CPU resources and model API quotas/credits from infinite loop exploits caused by malicious workspace files with indirect prompt injection.

Details

  • Turn Tracking per unique request: Added promptTurnCount to GeminiClient which tracks recursive turn depth scoped precisely to the unique prompt_id of the user's single query.
  • Auto-reset on New Prompts: Correctly resets the count to 0 whenever a new prompt is initiated (transitioning to a new prompt_id).
  • Configurable Override Support: If maxSessionTurns is set by the user to a positive integer, that limit is honored; otherwise, it defaults to a safe and robust limit of 15 turns.
  • Graceful Termination: Yields a MaxSessionTurns event and aborts recursive processing, allowing the CLI layers to gracefully terminate execution.
  • Comprehensive Unit Tests: Covered default limit checks, reset capability across different prompt IDs, and custom limits inside packages/core/src/core/client.test.ts.

Related Issues

Resolves the infinite loop issue described in issue.md.

How to Validate

  1. Run the new suite of unit tests for the core client:
    npm test -w @google/gemini-cli-core -- src/core/client.test.ts
  2. Verify all checks and tests pass with:
    npm run lint

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed)
  • Added/updated tests (if needed)
  • Noted breaking changes (if any)
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

@amelidev amelidev requested a review from a team as a code owner June 26, 2026 20:59
@github-actions github-actions Bot added the size/m A medium sized PR label Jun 26, 2026
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 430
  • Additions: +421
  • Deletions: -9
  • Files changed: 20

@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 introduces a safety mechanism to the core agent reasoning engine to mitigate potential infinite loop exploits. By enforcing a maximum turn limit per request, the system is better protected against malicious inputs that could otherwise exhaust local CPU resources or API quotas. The implementation includes flexible configuration options and robust unit testing to ensure reliable behavior across different usage scenarios.

Highlights

  • Recursive Turn Limiting: Implemented a strict limit of 15 recursive reasoning turns per user request to prevent infinite loops and protect system resources.
  • Configurable Limits: Added support for a custom maxSessionTurns configuration, allowing users to override the default limit.
  • State Management: Introduced promptTurnCount tracking scoped to unique prompt_ids, ensuring the counter resets correctly when a new request begins.
  • Graceful Error Handling: Added a MaxSessionTurns event to signal when the limit is reached, allowing for clean termination of recursive processes.
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 the 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 counterproductive. 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 introduces a mechanism to limit recursive turns per single prompt_id in GeminiClient. It tracks the turn count using a new promptTurnCount property, resetting it when the prompt_id changes. If the turn count exceeds the configured maximum (defaulting to 15), it yields a MaxSessionTurns event and terminates the turn. Corresponding unit tests have been added to verify both the default and custom limits, as well as the reset behavior. There are no review comments, so no additional feedback is provided.

@gemini-cli gemini-cli Bot added the status/need-issue Pull requests that need to have an associated issue. label Jun 26, 2026
@amelidev

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 introduces a mechanism to limit recursive turns per single prompt_id in GeminiClient to prevent infinite loops, defaulting to 15 turns or respecting a custom configured limit. It also adds a comprehensive test suite to verify this behavior. Feedback points out a leftover debug statement in the test file that writes to ./debug-test.log and should be removed.

Comment thread packages/core/src/core/client.test.ts Outdated
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
@amelidev

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 introduces a mechanism to limit recursive turns per single prompt ID to a default of 15 (or a custom configured limit) within the GeminiClient and adds corresponding unit tests. However, tracking this limit using a single instance variable (promptTurnCount) introduces a concurrency hazard where interleaved concurrent requests can corrupt the count. The review feedback correctly suggests refactoring this to use a concurrency-safe Map (promptTurnCounts) keyed by prompt_id across the client implementation and tests.

Note: Security Review did not run due to the size of the PR.

Comment thread packages/core/src/core/client.ts
Comment thread packages/core/src/core/client.ts
Comment thread packages/core/src/core/client.test.ts
amelidev and others added 3 commits June 29, 2026 16:30
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@amelidev

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 introduces a mechanism to limit recursive turns per single prompt_id in GeminiClient to prevent infinite loops. It tracks turn counts using a new promptTurnCounts map, enforcing a default limit of 15 turns or a custom limit if configured. Comprehensive unit tests have been added to verify this behavior, including limit enforcement and counter resets on new prompt IDs. I have no feedback to provide as there are no review comments to evaluate.

@amelidev

Copy link
Copy Markdown
Contributor Author

Ready to approve, all the feedback adressed

@DavidAPierce

Copy link
Copy Markdown
Contributor

Overall, this looks great. It does have a quick adjustment I'd recommend implementing before I give it an LGTM:

Memory Management (Potential Leak)

The promptTurnCounts Map is defined as:

private readonly promptTurnCounts = new Map<string, number>();

It accumulates entries for every unique prompt_id processed by the client. In a long-running session (e.g., if the client is kept alive across many user interactions), this Map will grow indefinitely, causing a minor memory leak.

Recommendation:
Clean up the Map when the prompt_id changes. There is already a block in sendMessageStream dedicated to resetting state for a new prompt. We should delete the old prompt's counter there.

    if (this.lastPromptId !== prompt_id) {
      this.loopDetector.reset(prompt_id, partListUnionToString(request));
      this.hookStateMap.delete(this.lastPromptId);
+     this.promptTurnCounts.delete(this.lastPromptId);
      this.lastPromptId = prompt_id;
      this.currentSequenceModel = null;
    }

This ensures that we only keep turn counts for active prompts, preventing unbounded map growth.

@DavidAPierce

Copy link
Copy Markdown
Contributor

While capping recursive turns per request is a strong and necessary safeguard against runaway agent loops, coupling this limit to maxSessionTurns and yielding GeminiEventType.MaxSessionTurns introduces semantic confusion and configuration friction that should be addressed before merging.


🟡 Architectural Concerns & Recommendations

1. Overloading maxSessionTurns for Two Different Granularities

The Issue

Currently, config.getMaxSessionTurns() and sessionTurnCount govern the total number of turns across the entire multi-prompt chat session (client.ts:L624-L631). This PR reuses getMaxSessionTurns() to also set the per-request recursive turn limit:

const maxAllowedTurns =
  this.config.getMaxSessionTurns() > 0
    ? this.config.getMaxSessionTurns()
    : 15; // default to 15 recursive turns per single user request

Why this is problematic

  • Loss of Runaway Protection on Long Sessions: If a user configures maxSessionTurns: 100 in settings.json (to allow a long chat session with 100 total interactions across many prompts), maxAllowedTurns for a single prompt (promptTurnCount) also becomes 100—completely disabling the intended 15-turn runaway loop protection for individual prompts.
  • Inability to Tune Complex Workflows: If a user wants to allow up to 25 or 30 recursive steps for complex autonomous engineering tasks (such as /goal or multi-file refactoring) while leaving the overall session length unlimited (maxSessionTurns = -1), they cannot raise the per-prompt limit without also artificially capping their total session length.

Recommendation

Introduce a separate configuration property (e.g., maxPromptTurns or maxTurnsPerRequest: 15 in settings.json and Config) rather than coupling promptTurnCount to maxSessionTurns.


2. Yielding Misleading Event Type (GeminiEventType.MaxSessionTurns)

The Issue

When this.promptTurnCount > maxAllowedTurns, sendMessageStream yields { type: GeminiEventType.MaxSessionTurns }.

Why this is problematic

The UI dialogs, error handlers, and telemetry interpret GeminiEventType.MaxSessionTurns as "The entire chat session has reached its configured session limit and ended." However, when triggered by promptTurnCount, only the current request has been aborted. If the user enters another prompt right after, prompt_id changes, promptTurnCount resets to 0, and the session continues normally.

Recommendation

  • Introduce a dedicated event type (e.g., GeminiEventType.MaxPromptTurns or GeminiEventType.MaxTurnsPerRequest) so that the UI can inform the user that the current task exceeded its reasoning limit rather than falsely stating the session ended.
  • Ensure telemetry differentiates between session-level limits (sessionTurnCount) and request-level limits (promptTurnCount).

3. Impact on Complex / Long-Running Autonomous Tasks (/goal, Plan Execution)

The Issue

A hardcoded default of 15 recursive turns per prompt might be too restrictive for complex autonomous engineering workflows (e.g., running /goal or refactoring across a monorepo where the agent lists directories, reads multiple files, edits 6 files, runs tests, and fixes compiler issues within a single prompt).

Observation

sendMessageStream already accepts a turns: number = MAX_TURNS (100) parameter (client.ts:L914), which decrements on recursive calls (boundedTurns - 1). When boundedTurns <= 1, processTurn yields MaxSessionTurns (client.ts:L752-L755).

Recommendation

Ensure that promptTurnCount works cooperatively with boundedTurns so that long-running commands (like /goal or /plan) can explicitly request a higher turn budget without being cut off prematurely by the default 15-turn boundary.

@gemini-cli

gemini-cli Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@amelidev

Copy link
Copy Markdown
Contributor Author

While capping recursive turns per request is a strong and necessary safeguard against runaway agent loops, coupling this limit to maxSessionTurns and yielding GeminiEventType.MaxSessionTurns introduces semantic confusion and configuration friction that should be addressed before merging.

🟡 Architectural Concerns & Recommendations

1. Overloading maxSessionTurns for Two Different Granularities

The Issue

Currently, config.getMaxSessionTurns() and sessionTurnCount govern the total number of turns across the entire multi-prompt chat session (client.ts:L624-L631). This PR reuses getMaxSessionTurns() to also set the per-request recursive turn limit:

const maxAllowedTurns =
  this.config.getMaxSessionTurns() > 0
    ? this.config.getMaxSessionTurns()
    : 15; // default to 15 recursive turns per single user request

Why this is problematic

  • Loss of Runaway Protection on Long Sessions: If a user configures maxSessionTurns: 100 in settings.json (to allow a long chat session with 100 total interactions across many prompts), maxAllowedTurns for a single prompt (promptTurnCount) also becomes 100—completely disabling the intended 15-turn runaway loop protection for individual prompts.
  • Inability to Tune Complex Workflows: If a user wants to allow up to 25 or 30 recursive steps for complex autonomous engineering tasks (such as /goal or multi-file refactoring) while leaving the overall session length unlimited (maxSessionTurns = -1), they cannot raise the per-prompt limit without also artificially capping their total session length.

Recommendation

Introduce a separate configuration property (e.g., maxPromptTurns or maxTurnsPerRequest: 15 in settings.json and Config) rather than coupling promptTurnCount to maxSessionTurns.

2. Yielding Misleading Event Type (GeminiEventType.MaxSessionTurns)

The Issue

When this.promptTurnCount > maxAllowedTurns, sendMessageStream yields { type: GeminiEventType.MaxSessionTurns }.

Why this is problematic

The UI dialogs, error handlers, and telemetry interpret GeminiEventType.MaxSessionTurns as "The entire chat session has reached its configured session limit and ended." However, when triggered by promptTurnCount, only the current request has been aborted. If the user enters another prompt right after, prompt_id changes, promptTurnCount resets to 0, and the session continues normally.

Recommendation

  • Introduce a dedicated event type (e.g., GeminiEventType.MaxPromptTurns or GeminiEventType.MaxTurnsPerRequest) so that the UI can inform the user that the current task exceeded its reasoning limit rather than falsely stating the session ended.
  • Ensure telemetry differentiates between session-level limits (sessionTurnCount) and request-level limits (promptTurnCount).

3. Impact on Complex / Long-Running Autonomous Tasks (/goal, Plan Execution)

The Issue

A hardcoded default of 15 recursive turns per prompt might be too restrictive for complex autonomous engineering workflows (e.g., running /goal or refactoring across a monorepo where the agent lists directories, reads multiple files, edits 6 files, runs tests, and fixes compiler issues within a single prompt).

Observation

sendMessageStream already accepts a turns: number = MAX_TURNS (100) parameter (client.ts:L914), which decrements on recursive calls (boundedTurns - 1). When boundedTurns <= 1, processTurn yields MaxSessionTurns (client.ts:L752-L755).

Recommendation

Ensure that promptTurnCount works cooperatively with boundedTurns so that long-running commands (like /goal or /plan) can explicitly request a higher turn budget without being cut off prematurely by the default 15-turn boundary.

Working on this suggestion

amelidev added 2 commits July 13, 2026 15:34
- Introduce 'maxPromptTurns' setting to manage recursive prompt turns separately from total session depth
- Yield 'GeminiEventType.MaxPromptTurns' when a single prompt's reasoning budget is exceeded
- Update UI error warning message to clarify reasoning limit was reached rather than session ending
- Update non-interactive CLI and agent sessions to support both prompt-level and session-level budgets
- Update all associated mocks, schemas, and test assertions to align with decoupled limits
…termination

- Allow callers of GeminiClient to request a custom recursive turn budget that overrides prompt-level cap
- Handle MaxPromptTurns events in legacy agent sessions and ACP sessions to clear active streams gracefully
- Add comprehensive test coverage in client.test.ts and legacy-agent-session.test.ts
@amelidev amelidev requested a review from a team as a code owner July 13, 2026 21:34
@github-actions github-actions Bot added the size/xl An extra large PR label Jul 13, 2026
@github-actions github-actions Bot added the size/l A large sized PR label Jul 13, 2026
@amelidev

Copy link
Copy Markdown
Contributor Author

/gemini review

@amelidev

Copy link
Copy Markdown
Contributor Author

While capping recursive turns per request is a strong and necessary safeguard against runaway agent loops, coupling this limit to maxSessionTurns and yielding GeminiEventType.MaxSessionTurns introduces semantic confusion and configuration friction that should be addressed before merging.

🟡 Architectural Concerns & Recommendations

1. Overloading maxSessionTurns for Two Different Granularities

The Issue

Currently, config.getMaxSessionTurns() and sessionTurnCount govern the total number of turns across the entire multi-prompt chat session (client.ts:L624-L631). This PR reuses getMaxSessionTurns() to also set the per-request recursive turn limit:

const maxAllowedTurns =
  this.config.getMaxSessionTurns() > 0
    ? this.config.getMaxSessionTurns()
    : 15; // default to 15 recursive turns per single user request

Why this is problematic

  • Loss of Runaway Protection on Long Sessions: If a user configures maxSessionTurns: 100 in settings.json (to allow a long chat session with 100 total interactions across many prompts), maxAllowedTurns for a single prompt (promptTurnCount) also becomes 100—completely disabling the intended 15-turn runaway loop protection for individual prompts.
  • Inability to Tune Complex Workflows: If a user wants to allow up to 25 or 30 recursive steps for complex autonomous engineering tasks (such as /goal or multi-file refactoring) while leaving the overall session length unlimited (maxSessionTurns = -1), they cannot raise the per-prompt limit without also artificially capping their total session length.

Recommendation

Introduce a separate configuration property (e.g., maxPromptTurns or maxTurnsPerRequest: 15 in settings.json and Config) rather than coupling promptTurnCount to maxSessionTurns.

2. Yielding Misleading Event Type (GeminiEventType.MaxSessionTurns)

The Issue

When this.promptTurnCount > maxAllowedTurns, sendMessageStream yields { type: GeminiEventType.MaxSessionTurns }.

Why this is problematic

The UI dialogs, error handlers, and telemetry interpret GeminiEventType.MaxSessionTurns as "The entire chat session has reached its configured session limit and ended." However, when triggered by promptTurnCount, only the current request has been aborted. If the user enters another prompt right after, prompt_id changes, promptTurnCount resets to 0, and the session continues normally.

Recommendation

  • Introduce a dedicated event type (e.g., GeminiEventType.MaxPromptTurns or GeminiEventType.MaxTurnsPerRequest) so that the UI can inform the user that the current task exceeded its reasoning limit rather than falsely stating the session ended.
  • Ensure telemetry differentiates between session-level limits (sessionTurnCount) and request-level limits (promptTurnCount).

3. Impact on Complex / Long-Running Autonomous Tasks (/goal, Plan Execution)

The Issue

A hardcoded default of 15 recursive turns per prompt might be too restrictive for complex autonomous engineering workflows (e.g., running /goal or refactoring across a monorepo where the agent lists directories, reads multiple files, edits 6 files, runs tests, and fixes compiler issues within a single prompt).

Observation

sendMessageStream already accepts a turns: number = MAX_TURNS (100) parameter (client.ts:L914), which decrements on recursive calls (boundedTurns - 1). When boundedTurns <= 1, processTurn yields MaxSessionTurns (client.ts:L752-L755).

Recommendation

Ensure that promptTurnCount works cooperatively with boundedTurns so that long-running commands (like /goal or /plan) can explicitly request a higher turn budget without being cut off prematurely by the default 15-turn boundary.

Concerns addressed 👍 After gemini review we are ready for your review again

@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 introduces a new configuration option, maxPromptTurns (defaulting to 15), to limit the number of recursive turns (tool calls and model responses) allowed for a single prompt before halting. This change includes updates to settings schemas, CLI configurations, client logic, error handling, and corresponding tests. The review feedback correctly identifies an issue in GeminiClient where the check this.config.getMaxPromptTurns() > 0 incorrectly overrides custom limits of 0 or -1 (unlimited) with the default value of 15, and suggests a correction to properly respect these limits.

Comment on lines +936 to 950
const configMax =
this.config.getMaxPromptTurns() > 0
? this.config.getMaxPromptTurns()
: 15; // default to 15 recursive turns per single user request

const maxAllowedTurns =
this.initialPromptTurns !== undefined &&
this.initialPromptTurns !== MAX_TURNS
? this.initialPromptTurns
: configMax;

if (this.promptTurnCount > maxAllowedTurns) {
yield { type: GeminiEventType.MaxPromptTurns };
return new Turn(this.getChat(), prompt_id);
}

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 current implementation of configMax overrides custom limits of 0 or -1 (unlimited) to the default value of 15 because of the check this.config.getMaxPromptTurns() > 0. Since -1 is explicitly documented as representing an unlimited number of turns, and 0 is a valid limit, we should retrieve the configured value directly and only apply the limit check if maxAllowedTurns >= 0.

    const configMax = this.config.getMaxPromptTurns();

    const maxAllowedTurns =
      this.initialPromptTurns !== undefined &&
      this.initialPromptTurns !== MAX_TURNS
        ? this.initialPromptTurns
        : configMax;

    if (maxAllowedTurns >= 0 && this.promptTurnCount > maxAllowedTurns) {
      yield { type: GeminiEventType.MaxPromptTurns };
      return new Turn(this.getChat(), prompt_id);
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! size/l A large sized PR size/m A medium sized PR size/xl An extra large PR status/need-issue Pull requests that need to have an associated issue. status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants