Skip to content

llmagent: add an opt-in no-progress guard for repeated tool-call/result loops #2346

Description

@hr-chang

Proposal

Background

In a real SWE-Bench trajectory for sphinx-doc__sphinx-10673, an LLMAgent repeated the same ordered two-tool bundle 115 consecutive times (assistant responses 16 through 130) without making progress.

The two Bash calls repeated in every round
cd /testbed && grep -rn "get_target_uri\|def get_target" sphinx/builders/html/__init__.py sphinx/builders/__init__.py | head
cd /testbed && sed -n '1000,1040p' sphinx/builders/html/__init__.py

After normalizing away call IDs, every repeated round had:

  • byte-identical assistant reasoning and content;
  • the same ordered tool names and canonical JSON arguments;
  • byte-identical observations as seen by the model.

Response IDs, timestamps, and usage records were distinct, so this was not duplicated persistence. The loop escaped only around the context boundary: one response ended with finish_reason=length near 262k total tokens, and after context compaction the model changed approach.

We then replayed exact recorded prefixes and requested only one next assistant response. Returned tools were not executed. The prefix ended after k completed repetitions of the historical action/result cycle.

Prompt-token counts matched the corresponding historical requests at every cut point (delta 0), which validates that the model received the same serialized history and observations.

Repetitions in prefix (k) GLM-5.2 deployment A GLM-5.2 deployment B
0 0/3 exact repeats 0/3 exact repeats
1 2/3 exact repeats 1/3 exact repeats
2 3/3 exact repeats 3/3 exact repeats
4 3/3 exact repeats 3/3 exact repeats
8 3/3 exact repeats 3/3 exact repeats
32 1/1 exact repeat 1/1 exact repeat

An “exact repeat” here means that the next assistant response cloned the full reasoning/content and ordered two-tool bundle, not merely similar commands. Exact repeats also occurred with zero cached input tokens, so prompt caching was not required for reproduction.

This evidence is consistent with a model/history attractor, not the framework replaying stale tool calls. It is not a claim that tRPC-Agent-Go caused the model behavior. The framework concern is defense in depth: a model can repeatedly execute an already-proven no-progress action long before a global call limit is reached.

The building blocks already exist—BeforeTool, AfterTool, CustomResult, tool-result-message replacement, and StopError—but applications currently need to assemble their own detector and define invocation state, canonicalization, multi-tool semantics, polling exclusions, diagnostics, and tests.

Related issues are complementary rather than duplicates:

Proposal

Consider an opt-in reusable no-progress guard, provided as a plugin, callback helper, or llmagent option.

A conservative initial policy could be:

  1. Build an ordered action-bundle fingerprint from each tool name and canonical JSON arguments.
  2. Compare the complete finalized observation bundle—the content that would be shown to the model after ordinary formatting/truncation, before adding a guard diagnostic.
  3. Run the first occurrence normally.
  4. If the next consecutive action bundle is identical, run it once more. If its finalized observation bundle is also identical, return a concise diagnostic asking the model to re-plan and arm the guard.
  5. If the model immediately selects the same action bundle again, short-circuit before the third execution or terminate via StopError.
  6. Reset the armed state after a different action bundle or changed observation.

For multi-tool rounds, comparison should preserve the model's original call order and occur after all results have reached a barrier; callback completion order should not define bundle identity.

Polling/background tools need per-tool opt-out or a custom progress comparator. Default behavior should remain unchanged.

Goals

  • Bound exact no-progress loops before the hard call/iteration limit.
  • Avoid unnecessary repeated tool side effects and latency.
  • Work for individual calls and ordered multi-tool bundles.
  • Make detection observable through an event/metric containing repeat count, tool names, and a safe fingerprint.
  • Allow tool-specific exclusions and policies.
  • Keep existing hard limits as the final safety net.

Non-Goals

  • Claim that the framework causes model repetition.
  • Treat every repeated tool call as an error.
  • Detect semantic similarity, arbitrary planning failures, or non-consecutive cycles such as A-B-A-B in the first version.
  • Replace MaxLLMCalls or MaxToolIterations.
  • Mandate immediate hard termination for every application.

Open Questions

  1. Should this live as a generic plugin, callback utility, or llmagent option?
  2. Should the built-in policy warn once and then stop, or only expose detection and let applications decide?
  3. What threshold and per-tool override API best supports legitimate polling?
  4. Should comparison happen after ToolResultMessages formatting so it exactly reflects model-visible content?
  5. How should bundle-level state be coordinated when tools execute in parallel?
  6. How should diagnostics appear in the event stream without leaking tool-result content?

A first conservative implementation could be disabled by default and test at least: identical action/result, identical action with changed result, changed action, ordered multi-tool bundles, parallel completion in a different order, reset after progress, and explicitly permitted polling.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions