Skip to content

fix(agent): detect and escalate repeated identical failing tool calls - #2338

Merged
ilblackdragon merged 1 commit into
stagingfrom
fix/2240-duplicate-tool-call-detection
Apr 13, 2026
Merged

ilblackdragon merged 1 commit into
stagingfrom
fix/2240-duplicate-tool-call-detection

Conversation

@zmanian

@zmanian zmanian commented Apr 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #2240

  • Adds a DuplicateToolCallTracker to the shared agentic loop that fingerprints each tool call batch by hashing (tool_name, canonicalized_args) and detects consecutive identical failing batches
  • After 3 consecutive duplicate failures: injects a warning message telling the LLM to try a different approach
  • After 5 consecutive duplicate failures: sets force_text = true to disable tool calls entirely, guaranteeing termination
  • Resets when the LLM calls different tools, any tool succeeds, or produces a text response
  • Extracts canonicalize_json_value from agent/routine.rs to util.rs for shared use
  • All three delegate implementations (ChatDelegate, JobDelegate, ContainerDelegate) report tool batch failure status via ReasoningContext.last_tool_batch_all_failed

Design

Follows the same counter + threshold + escalation pattern as the existing tool intent nudge (consecutive_tool_intent_nudges / max_tool_intent_nudges) and truncation detection (truncation_count / force_text) mechanisms already in the agentic loop.

Test plan

  • Unit tests for DuplicateToolCallTracker (fingerprinting, counting, reset on success, reset on different args, canonicalization of key order)
  • Unit tests for extracted canonicalize_json_value in util.rs
  • Integration test: 3 consecutive duplicate failures inject warning message
  • Integration test: 5 consecutive duplicate failures set force_text = true
  • Integration test: text response between duplicate failures resets the streak
  • cargo clippy --all --benches --tests --examples --all-features passes with zero warnings
  • cargo test passes (4713 of 4714 pass; 1 pre-existing flaky SQLite test unrelated to this change)

Generated with Claude Code

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: tool Tool infrastructure scope: tool/builtin Built-in tools scope: llm LLM integration scope: worker Container worker scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 11, 2026

@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 Ethereum wallet integration using WalletConnect v2, adding tools for wallet pairing and transaction submission, a callback registry for asynchronous results, and logic within the agentic loop to handle duplicate failing tool calls. Critical feedback identifies a potential deadlock caused by holding a mutex lock across an await point in the session listener and notes that the transaction tool is currently incomplete as it does not actually dispatch requests to the wallet. Further improvements are suggested regarding the security gating of high-risk tools and the robustness of the failure detection logic, which currently relies on fragile string matching.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
Comment on lines +76 to +78
let mut client = self.wc_client.lock().await;
if let Some(ref wc) = *client {
match wc.next().await {

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.

critical

Holding the wc_client Mutex lock across the wc.next().await call in poll_event is a critical concurrency issue. Since poll_event is called in a continuous loop by the WalletConnectListener (in listener.rs), it will hold the lock while awaiting the next network event. This prevents any other part of the system, such as the request method (lines 100-109), from acquiring the lock to send transactions. This effectively deadlocks or severely starves transaction requests while the listener is active. The lock should only be held for short, non-blocking operations, or the client should be designed for concurrent access (e.g., by cloning a handle if the underlying client supports it).

Comment thread src/tools/builtin/ethereum/transact.rs Outdated
Comment on lines +100 to +171
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

// Must be paired first.
if !self.session.is_paired().await {
return Err(EthereumError::NotPaired.into());
}

// Extract and validate parameters.
let to = require_str(&params, "to")?;
if !is_valid_eth_address(to) {
return Err(EthereumError::InvalidAddress {
address: to.to_string(),
}
.into());
}

let value = require_str(&params, "value")?;
let data = params.get("data").and_then(|v| v.as_str());
let chain_id = params.get("chain_id").and_then(|v| v.as_u64());

// Generate a correlation ID for the async callback.
let correlation_id = uuid::Uuid::new_v4().to_string();

// Extract channel/thread from context metadata for callback routing.
let channel = ctx
.metadata
.get("source_channel")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let thread_id = ctx
.metadata
.get("thread_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

// Register callback so the result can be routed back.
self.callback_registry
.register(
correlation_id.clone(),
CallbackMetadata {
tool_name: "wallet_transact".to_string(),
user_id: ctx.user_id.clone(),
thread_id,
channel,
},
)
.await;

let mut result = json!({
"status": "pending",
"correlation_id": correlation_id,
"to": to,
"value": value,
"message": "Transaction submitted to wallet for approval. \
The wallet owner must confirm on their device."
});

if data.is_some() {
result["data_present"] = json!(true);
}
if let Some(cid) = chain_id {
result["chain_id"] = json!(cid);
}

Ok(ToolOutput::success(result, start.elapsed()))
}

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.

critical

The WalletTransactTool::execute implementation appears to be incomplete. It validates parameters and registers a callback, but it never actually calls self.session.request() to submit the transaction to the paired wallet. As a result, the tool will always return a "pending" status to the LLM, but no request will ever reach the user's wallet. You should call session.request("eth_sendTransaction", ...) (or the appropriate JSON-RPC method) before returning the success output.

Comment thread src/tools/builtin/ethereum/transact.rs Outdated
Comment on lines +83 to +86
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
// The wallet itself is the approval mechanism.
ApprovalRequirement::Never
}

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.

security-high high

The WalletTransactTool is designated as RiskLevel::High but returns ApprovalRequirement::Never. While Rule 11 allows for autonomous execution after command-level approval, the tool should still be integrated into the approval system to ensure that the initial gating occurs. Per Rule 13, the default for requires_approval_for should be false to support auto-approval, but the tool's requirement level should reflect its risk to trigger the appropriate UI flows and prevent unauthorized interactions or LLM spamming.

References
  1. For destructive or high-risk tools, user approval should be handled at the command-level (e.g., UI modal) before dispatch, allowing the tool to then run autonomously.
  2. The default implementation of Tool.requires_approval_for should return false to allow auto-approval to proceed unless a specific override is triggered.

Comment thread src/agent/agentic_loop.rs Outdated
Comment on lines +366 to +372
let all_failed = !tool_results.is_empty()
&& tool_results.iter().all(|m| {
// Tool failure messages are wrapped by SafetyLayer:
// <tool_output name="...">Tool 'x' failed: ...</tool_output>
m.content.contains("failed:")
&& m.content.contains("Tool '")
});

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.

medium

The all_failed detection logic is brittle as it relies on specific string matching ("failed:" and "Tool '"). This dependency on the exact output format of the SafetyLayer or tools makes the loop-breaking mechanism fragile. If the error message format changes or if a tool returns a success message that happens to contain these substrings (e.g., in a user-provided string), the detection will be inaccurate. Consider using structured error indicators if available in the ChatMessage or tool result to distinguish failures from successes more reliably.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52c328a08a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/builtin/ethereum/transact.rs Outdated
Comment on lines +154 to +160
let mut result = json!({
"status": "pending",
"correlation_id": correlation_id,
"to": to,
"value": value,
"message": "Transaction submitted to wallet for approval. \
The wallet owner must confirm on their device."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Submit transaction over WalletConnect before returning pending

wallet_transact reports that the transaction was submitted and only registers a callback entry, but this execution path never invokes WalletConnectSession::request(...) (or any other send path) before returning success. As a result, users get a pending correlation ID even though no wallet signature request is actually dispatched, so these entries can only age out via timeout instead of completing.

Useful? React with 👍 / 👎.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
Comment on lines +76 to +79
let mut client = self.wc_client.lock().await;
if let Some(ref wc) = *client {
match wc.next().await {
Ok(event) => event,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid awaiting wallet events while holding wc_client mutex

poll_event holds wc_client's mutex guard across wc.next().await, and the background listener calls this in a tight loop. Once paired, that can block other methods that need the same mutex (for example request and client replacement during re-pair) until an event arrives, causing transaction/re-pair operations to hang under idle or low-event conditions.

Useful? React with 👍 / 👎.

Comment thread src/agent/agentic_loop.rs
if !crate::llm::llm_signals_tool_intent(&text) {
consecutive_tool_intent_nudges = 0;
}
dup_tracker.reset();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset duplicate-failure tracker before tool-intent continue

The duplicate-tool tracker reset is placed after the tool-intent nudge branch, but that branch continues early, so text-only tool-intent iterations do not clear duplicate-failure state. This can incorrectly escalate to warning/force_text even when failures are not consecutive iterations, because the counter survives across those nudge-only text responses.

Useful? React with 👍 / 👎.

@zmanian
zmanian force-pushed the fix/2240-duplicate-tool-call-detection branch from c76d048 to a2cbed9 Compare April 11, 2026 22:11
@zmanian zmanian changed the title fix(agent): detect and break duplicate failing tool call loops fix(agent): detect and escalate repeated identical failing tool calls Apr 11, 2026
…#2240)

When a tool call fails, the LLM often retries the exact same call with
identical args, repeating up to max_iterations (50 for chat, 10 for jobs)
with no mechanism to break the loop.

Add a DuplicateToolCallTracker to the agentic loop that fingerprints each
batch of tool calls by hashing (tool_name, canonicalized_args). When the
same fingerprint appears in consecutive iterations and all tools fail:

- After 3 consecutive duplicates: inject a warning message telling the
  LLM to try a different approach
- After 5 consecutive duplicates: set force_text = true to disable tool
  calls entirely

The tracker resets when the LLM calls different tools, any tool succeeds,
or a text response is produced. This follows the same counter + threshold
+ escalation pattern as the existing tool intent nudge and truncation
detection mechanisms.

Also extracts canonicalize_json_value from agent/routine.rs to util.rs
for reuse across modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zmanian
zmanian force-pushed the fix/2240-duplicate-tool-call-detection branch from dbdb088 to 8f8e06b Compare April 12, 2026 19:20

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code Review

Overview

Adds a DuplicateToolCallTracker to the shared agentic loop that fingerprints each tool call batch by hashing (tool_name, canonicalized_args). When the LLM repeatedly issues identical failing tool calls:

  • 3 consecutive → injects a warning message telling the LLM to try a different approach
  • 5 consecutive → sets force_text = true to disable tool calls entirely, guaranteeing termination

Resets on: different tools called, any tool succeeds, or text response produced. Follows the existing escalation patterns (tool intent nudge, truncation detection).

Design Assessment

This is well-designed. It correctly follows the existing consecutive_tool_intent_nudges / truncation_count → force_text escalation pattern already in the agentic loop. The three-state progression (silent → warn → force) gives the LLM a chance to self-correct before cutting it off.

All three delegates (ChatDelegate, JobDelegate, ContainerDelegate) are updated to report last_tool_batch_all_failed, which is the right approach — the loop engine detects the pattern, the delegates just report facts.

Code Quality

Positives:

  • No .unwrap() / .expect() in production code
  • tracing::debug! used correctly (not info!)
  • Clean extraction of canonicalize_json_value to util.rs — the routine.rs local function now delegates, no behavior change
  • DuplicateToolCallTracker is private to the module — correct encapsulation
  • Thorough unit tests (5 for the tracker, 3 for canonicalization, 3 integration tests for the loop)
  • The ChatDelegate correctly counts both PreflightOutcome::Rejected and execution errors as failures, while auth-required and needs-approval are not counted (correct — those are flow control)

Issues

Severity Issue
Low Batch order sensitivity. Fingerprint hashes tool calls in array order, so [A, B] and [B, A] produce different fingerprints. If the LLM reorders tools within a batch but uses the same calls, the tracker won't detect the duplicate. Rare in practice (LLMs tend to produce consistent ordering), but worth a comment in the fingerprint doc.
Low Warning messages accumulate. At counts 3, 4, and 5, a warning is pushed each time — the LLM sees 3 identical warning messages in context. Consider pushing only at the threshold crossing (count == 3 and count == 5) rather than >=, or accept the accumulation as increasing pressure.
Low DefaultHasher is not guaranteed stable across Rust versions. Fine here since the hash is only compared within a single process lifetime — no persistence or cross-process use. Worth a one-line comment noting this is intentional.
Nit tc.arguments.clone() in fingerprint. Clones the entire JSON value for canonicalization. Fine for typical tool arguments since they're bounded by the LLM output token limit.

Design Questions

  1. Should truncated tool calls (FinishReason::Length) interact with the duplicate tracker? Currently, truncated batches hit the continue path before reaching the duplicate tracking code. This means a truncated batch that happens to match the previous batch won't increment the duplicate counter. This seems correct (truncated calls are discarded before execution), but worth confirming the intent.

  2. The last_tool_batch_all_failed field on ReasoningContext is a mutable flag used as a communication channel between the delegate and the loop. This follows the existing force_text pattern and works because the loop is single-threaded. The doc comment explains the contract well.

Test Coverage

Excellent:

  • 5 tracker unit tests: no-duplicate-on-success, counting, reset-on-success, different-args-restart, canonicalization
  • 4 canonicalization tests: key sorting, recursive sorting, array order preservation, scalar passthrough
  • 3 integration tests: warning injection at threshold 3, force_text at threshold 5, text-response reset

Verdict: Approve ✓

Clean, well-tested, follows established patterns. The issues are all low-severity suggestions.

This was referenced Apr 16, 2026
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…nearai#2240) (nearai#2338)

When a tool call fails, the LLM often retries the exact same call with
identical args, repeating up to max_iterations (50 for chat, 10 for jobs)
with no mechanism to break the loop.

Add a DuplicateToolCallTracker to the agentic loop that fingerprints each
batch of tool calls by hashing (tool_name, canonicalized_args). When the
same fingerprint appears in consecutive iterations and all tools fail:

- After 3 consecutive duplicates: inject a warning message telling the
  LLM to try a different approach
- After 5 consecutive duplicates: set force_text = true to disable tool
  calls entirely

The tracker resets when the LLM calls different tools, any tool succeeds,
or a text response is produced. This follows the same counter + threshold
+ escalation pattern as the existing tool intent nudge and truncation
detection mechanisms.

Also extracts canonicalize_json_value from agent/routine.rs to util.rs
for reuse across modules.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: dependencies Dependency updates scope: llm LLM integration scope: tool/builtin Built-in tools scope: tool Tool infrastructure scope: worker Container worker size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent retries same failing tool call up to 50 times with no duplicate detection

2 participants