Skip to content

fix: prevent infinite loop in performAgentChain on repeating tool calls#178

Open
mason5052 wants to merge 2 commits intovxcontrol:masterfrom
mason5052:fix/agent-chain-iteration-cap
Open

fix: prevent infinite loop in performAgentChain on repeating tool calls#178
mason5052 wants to merge 2 commits intovxcontrol:masterfrom
mason5052:fix/agent-chain-iteration-cap

Conversation

@mason5052
Copy link
Contributor

@mason5052 mason5052 commented Mar 6, 2026

Description of the Change

Problem

The performAgentChain loop in performer.go is an unbounded for {} with no iteration cap. When a model repeatedly calls the same tool, the repeatingDetector fires and returns a message (nil error), so the loop never breaks. This can result in 4,800+ iterations in a single session, consuming resources indefinitely.

Closes #175

Solution

Add two safety mechanisms:

  1. Iteration cap (maxAgentChainIterations = 100): Hard limit on the main loop. Normal pentest flows use far fewer iterations; 100 is generous while preventing runaway loops.

  2. Repeating escalation (maxSoftDetectionsBeforeAbort = 4): After 4 consecutive soft detection warnings (7 total identical calls), escalate from a soft message to an actual error that terminates the chain. The existing soft response is preserved for the first 4 detections, giving the LLM a chance to course-correct.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Areas Affected

  • Core Services (Backend API)

Testing and Verification

Test Configuration

  • PentAGI Version: master
  • Go Version: 1.24

Test Steps

  1. Verified both constants follow existing naming patterns (maxRetriesToCallSimpleChain, etc.)
  2. Confirmed for iteration := 0; ; iteration++ preserves all existing loop semantics
  3. Verified len(detector.funcCalls) is accessible (same package) and correctly tracks consecutive identical calls (resets on different calls per helpers.go:detect())
  4. Confirmed escalation threshold math: RepeatingToolCallThreshold(3) + maxSoftDetectionsBeforeAbort(4) = 7, so error fires on the 7th consecutive identical call
  5. Timeline: calls 1-2 build up, call 3 triggers first detection, calls 3-6 get soft warnings (4 soft detections), call 7 aborts

Security Considerations

No security impact. This is a resource exhaustion prevention fix.

Checklist

  • My code follows the project's coding standards
  • All new and existing tests pass
  • I have run go fmt and go vet
  • Security implications considered
  • Changes are backward compatible

The performAgentChain loop has no iteration cap, allowing infinite loops
when a model repeatedly calls the same tool. The repeating detector
returns a message (not an error), so the loop never breaks.

Add two safety mechanisms:
- Hard cap of 100 iterations on the main agent chain loop
- Escalation to error after 5 consecutive repeating detections
  (8 total identical calls: 3 before first detection + 5 detections)

The soft "please try another tool" response is preserved for the first
4 detections, giving the LLM a chance to course-correct before aborting.

Closes vxcontrol#175

Signed-off-by: mason5052 <ehehwnwjs5052@gmail.com>
Copilot AI review requested due to automatic review settings March 6, 2026 00:39
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Prevents runaway performAgentChain executions in backend/pkg/providers/performer.go by adding hard stops for excessive looping and repeated tool-call patterns, addressing resource exhaustion described in #175.

Changes:

  • Add a maximum iteration cap to the main performAgentChain loop.
  • Escalate repeating tool-call detections from a soft warning to a terminating error after a configured threshold.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if len(detector.funcCalls) >= RepeatingToolCallThreshold+maxRepeatingDetectionsBeforeErr-1 {
errMsg := fmt.Sprintf("tool '%s' repeated %d times consecutively, aborting chain", funcName, len(detector.funcCalls))
logger.WithField("repeat_count", len(detector.funcCalls)).Error(errMsg)
return "", fmt.Errorf("%s", errMsg)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Similar to the iteration-cap error, fmt.Errorf("%s", errMsg) is redundant formatting. Consider using errors.New(errMsg) (or fmt.Errorf(errMsg)) to keep error construction idiomatic.

Suggested change
return "", fmt.Errorf("%s", errMsg)
return "", errors.New(errMsg)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Switched to errors.New(errMsg) in the follow-up commit (917b371).

if iteration >= maxAgentChainIterations {
msg := fmt.Sprintf("agent chain exceeded maximum iterations (%d)", maxAgentChainIterations)
logger.WithField("iteration", iteration).Error(msg)
return fmt.Errorf("%s", msg)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

fmt.Errorf("%s", msg) is redundant and loses the opportunity to use the more idiomatic errors.New(msg) (or fmt.Errorf(msg) when no formatting is needed). Consider switching to one of those to keep error creation consistent and avoid unnecessary formatting.

Suggested change
return fmt.Errorf("%s", msg)
return errors.New(msg)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Switched to errors.New(msg) in the follow-up commit (917b371). Since msg is already a fully formatted string, errors.New is the right choice here.

Comment on lines +33 to +35
maxAgentChainIterations = 100
maxRepeatingDetectionsBeforeErr = 5
delayBetweenRetries = 5 * time.Second
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The constant name maxRepeatingDetectionsBeforeErr is a bit ambiguous because the code escalates on the Nth repeating detection (via the -1 in the threshold math), not after N detections. Consider renaming it to something like “...ToAbort/ToError” or adjusting the condition so the name matches the behavior.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point on the naming ambiguity. Renamed to maxSoftDetectionsBeforeAbort = 4 and removed the -1 from the condition. Now the math is straightforward: RepeatingToolCallThreshold(3) + maxSoftDetectionsBeforeAbort(4) = 7, and the name accurately describes the behavior (4 soft detection responses before abort). Fixed in 917b371.

Comment on lines +267 to +269
if len(detector.funcCalls) >= RepeatingToolCallThreshold+maxRepeatingDetectionsBeforeErr-1 {
errMsg := fmt.Sprintf("tool '%s' repeated %d times consecutively, aborting chain", funcName, len(detector.funcCalls))
logger.WithField("repeat_count", len(detector.funcCalls)).Error(errMsg)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

With RepeatingToolCallThreshold = 3 (helpers.go), the current condition aborts when len(detector.funcCalls) >= 3 + maxRepeatingDetectionsBeforeErr - 1, i.e. on the 7th consecutive identical call when maxRepeatingDetectionsBeforeErr is 5. The PR description mentions “8 total identical calls”; if that’s the intended behavior, the threshold math here needs to be adjusted (or the description updated).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right -- the description mentioned "8 total identical calls" which was inaccurate. The abort triggers on the 7th consecutive identical call. Timeline: calls 1-2 build up, call 3 triggers first detection, calls 3-6 get soft warnings (4 soft detections), call 7 aborts. Updated the constant naming and math in 917b371 to make this clearer, and will update the PR description.

- Rename maxRepeatingDetectionsBeforeErr to maxSoftDetectionsBeforeAbort
  for clarity (name now matches behavior: 4 soft warnings before abort)
- Adjust threshold value from 5 to 4 and remove -1 from condition
  (same runtime behavior: abort on 7th consecutive identical call)
- Use errors.New() instead of fmt.Errorf("%s", msg) for non-formatted
  error strings (more idiomatic Go)

Signed-off-by: mason5052 <ehehwnwjs5052@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

performAgentChain has no iteration cap — infinite loop on repeating tool calls

2 participants