fix(llm): nudge LLM when it expresses tool intent without calling tools (#653)

* fix(llm): nudge LLM when it expresses tool intent without calling tools

Non-Anthropic models (especially GLM-5 via NEAR AI) frequently output
text like "Let me search for X" without including tool_calls, creating
a frustrating loop where the user waits but nothing happens.

Add llm_signals_tool_intent() detection that matches intent phrases
("let me search", "I'll fetch") while excluding conversational phrases
("let me explain", "let me know") and content inside code blocks.
When detected, inject a nudge message telling the model to actually
call the tool. Cap at 2 consecutive nudges to avoid infinite loops.

Applied to all three agentic loops: dispatcher (interactive chat),
agent/worker (background jobs), and worker/runtime (sandbox containers).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(nudge): address PR #653 review comments

1. Update doc comment to match implementation (code blocks only, not quotes)
2. Use match_indices() instead of find() to check all prefix occurrences
3. Add !available_tools.is_empty() guard in dispatcher nudge check
4. Reset consecutive_tool_intent_nudges on non-intent text responses
5. Add regression test for shadowed prefix detection

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(nudge): address second round of PR #653 review comments

1. Strip double-quoted strings in tool-intent detection to avoid false
   positives on quoted prose like `"Let me search the database"`.
2. Only reset consecutive_tool_intent_nudges when text does NOT signal
   intent — preserves the 2-nudge cap when intent is detected but cap
   is already reached.
3. Fix assertion message in nudge_cap test to report correct call index.
4. Add regression test for quoted strings outside code blocks.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-07 08:05:55 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3f22f4321d
commit 8fbb782090
9 changed files with 496 additions and 6 deletions
+21
View File
@@ -138,6 +138,8 @@ impl Agent {
let force_text_at = max_tool_iterations;
let nudge_at = max_tool_iterations.saturating_sub(1);
let mut iteration = 0;
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
let mut consecutive_tool_intent_nudges: u32 = 0;
loop {
iteration += 1;
// Hard ceiling one past the forced-text iteration (should never be reached
@@ -294,6 +296,24 @@ impl Agent {
match output.result {
RespondResult::Text(text) => {
// Nudge the LLM if it expressed tool intent without calling tools.
// This is common with non-Anthropic models (e.g. GLM-5 via NEAR AI)
// that output "Let me search…" but don't issue tool_calls.
if !force_text
&& !context.available_tools.is_empty()
&& consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
&& crate::llm::llm_signals_tool_intent(&text)
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
iteration,
"LLM expressed tool intent without calling a tool, nudging"
);
context_messages.push(ChatMessage::assistant(&text));
context_messages.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
continue;
}
// Strip internal "[Called tool ...]" text that can leak when
// provider flattening (e.g. NEAR AI) converts tool_calls to
// plain text and the LLM echoes it back.
@@ -304,6 +324,7 @@ impl Agent {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
// Add the assistant message with tool_calls to context.
// OpenAI protocol requires this before tool-result messages.
context_messages.push(ChatMessage::assistant_with_tool_calls(
+25 -5
View File
@@ -305,6 +305,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let mut iteration = 0;
const MAX_CONSECUTIVE_RATE_LIMITS: usize = 10;
let mut consecutive_rate_limits = 0usize;
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
let mut consecutive_tool_intent_nudges: u32 = 0;
// Initial tool definitions for planning (will be refreshed in loop)
reason_ctx.available_tools = self.tools().tool_definitions().await;
@@ -502,17 +504,34 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}),
);
// Give it one more chance to select a tool
if iteration > 3 && iteration % 5 == 0 {
reason_ctx.messages.push(ChatMessage::user(
"Are you stuck? Do you need help completing this job?",
));
// Nudge the LLM if it expressed tool intent without calling tools
let signals_intent = !reason_ctx.available_tools.is_empty()
&& crate::llm::llm_signals_tool_intent(&response);
if signals_intent && consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
job_id = %self.job_id,
"LLM expressed tool intent without calling a tool, nudging"
);
reason_ctx
.messages
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
} else if !signals_intent {
consecutive_tool_intent_nudges = 0;
if iteration > 3 && iteration % 5 == 0 {
// Generic fallback nudge
reason_ctx.messages.push(ChatMessage::user(
"Are you stuck? Do you need help completing this job?",
));
}
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
// Model returned tool calls - execute them
tracing::debug!(
"Job {} respond_with_tools returned {} tool calls",
@@ -558,6 +577,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
} else if selections.len() == 1 {
consecutive_tool_intent_nudges = 0;
// Single tool: execute directly
let selection = &selections[0];
tracing::debug!(
+1 -1
View File
@@ -30,7 +30,7 @@ pub use provider::{
};
pub use reasoning::{
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
TokenUsage, ToolSelection, is_silent_reply,
TOOL_INTENT_NUDGE, TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent,
};
pub use recording::RecordingLlm;
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
+206
View File
@@ -16,6 +16,129 @@ use crate::safety::SafetyLayer;
/// The dispatcher should check for this and suppress the message.
pub const SILENT_REPLY_TOKEN: &str = "NO_REPLY";
/// Nudge message injected when the LLM expresses intent to use a tool but
/// doesn't include any `tool_calls` in its response.
pub const TOOL_INTENT_NUDGE: &str = "\
You said you would perform an action, but you did not include any tool calls.\n\
Do NOT describe what you intend to do — actually call the tool now.\n\
Use the tool_calls mechanism to invoke the appropriate tool.";
/// Detect when an LLM response expresses intent to call a tool without
/// actually issuing tool calls. Returns `true` if the text contains phrases
/// like "Let me search …" or "I'll fetch …" outside of fenced/indented code blocks.
///
/// Exclusion phrases (e.g. "let me explain") are checked first to avoid
/// false positives on conversational language.
pub fn llm_signals_tool_intent(response: &str) -> bool {
// Extract only non-code lines with quoted strings removed
let text = strip_code_blocks(response);
let lower = text.to_lowercase();
// Exclusion phrases — if any appear, bail out immediately
const EXCLUSIONS: &[&str] = &[
"let me explain",
"let me know",
"let me think",
"let me summarize",
"let me clarify",
"let me describe",
"let me help",
"let me understand",
"let me break",
"let me outline",
"let me walk you",
"let me provide",
"let me suggest",
"let me elaborate",
"let me start by",
];
if EXCLUSIONS.iter().any(|e| lower.contains(e)) {
return false;
}
const PREFIXES: &[&str] = &["let me ", "i'll ", "i will ", "i'm going to "];
const ACTION_VERBS: &[&str] = &[
"search",
"look up",
"check",
"fetch",
"find",
"read the",
"write the",
"create",
"run the",
"execute",
"query",
"retrieve",
"add it",
"add the",
"add this",
"add that",
"update the",
"delete",
"remove the",
"look into",
];
for prefix in PREFIXES {
for (i, _) in lower.match_indices(prefix) {
let after = &lower[i + prefix.len()..];
for verb in ACTION_VERBS {
if after.starts_with(verb) || after.contains(&format!(" {verb}")) {
return true;
}
}
}
}
false
}
/// Strip fenced code blocks (``` ... ```), indented code lines (4+ spaces / tab),
/// and double-quoted strings so that tool-intent detection only fires on prose.
fn strip_code_blocks(text: &str) -> String {
let mut result = String::new();
let mut in_fence = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
// Skip indented code lines (4+ spaces or tab)
if line.starts_with(" ") || line.starts_with('\t') {
continue;
}
// Strip double-quoted strings to avoid matching intent phrases inside quotes
let stripped = strip_quoted_strings(line);
result.push_str(&stripped);
result.push('\n');
}
result
}
/// Remove double-quoted string literals from a line.
fn strip_quoted_strings(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut in_quote = false;
let mut prev = '\0';
for ch in line.chars() {
if ch == '"' && prev != '\\' {
in_quote = !in_quote;
continue;
}
if !in_quote {
result.push(ch);
}
prev = ch;
}
result
}
/// Check if a response is a silent reply (the agent has nothing to say).
///
/// Returns true if the trimmed text is exactly the silent reply token or
@@ -2008,4 +2131,87 @@ That's my plan."#;
assert!(cleaned.contains("Let me fetch that."));
assert!(cleaned.contains("Here are the results."));
}
// ---- Tool intent detection tests ----
#[test]
fn test_llm_signals_tool_intent_true_positives() {
assert!(llm_signals_tool_intent("Let me search for that file."));
assert!(llm_signals_tool_intent("I'll fetch the data now."));
assert!(llm_signals_tool_intent("I'm going to check the logs."));
assert!(llm_signals_tool_intent("Let me add it now."));
assert!(llm_signals_tool_intent("I will run the tests to verify."));
assert!(llm_signals_tool_intent("I'll look up the documentation."));
assert!(llm_signals_tool_intent("Let me read the file contents."));
assert!(llm_signals_tool_intent("I'm going to execute the command."));
}
#[test]
fn test_llm_signals_tool_intent_true_negatives_conversational() {
assert!(!llm_signals_tool_intent("Let me explain how this works."));
assert!(!llm_signals_tool_intent(
"Let me know if you need anything."
));
assert!(!llm_signals_tool_intent("Let me think about this."));
assert!(!llm_signals_tool_intent("Let me summarize the findings."));
assert!(!llm_signals_tool_intent("Let me clarify what I mean."));
}
#[test]
fn test_llm_signals_tool_intent_exclusion_takes_precedence() {
// Exclusion phrase present alongside intent → false
assert!(!llm_signals_tool_intent(
"Let me explain the approach, then I'll search for the file."
));
}
#[test]
fn test_llm_signals_tool_intent_ignores_code_blocks() {
let with_code = "Here's the updated code:\n\n```\nfn main() {\n println!(\"Let me search the database\");\n}\n```";
assert!(!llm_signals_tool_intent(with_code));
}
#[test]
fn test_llm_signals_tool_intent_ignores_indented_code() {
let with_indent =
"Here's the code:\n\n println!(\"I'll fetch the data\");\n\nThat's it.";
assert!(!llm_signals_tool_intent(with_indent));
}
#[test]
fn test_llm_signals_tool_intent_ignores_plain_text() {
assert!(!llm_signals_tool_intent("The task is complete."));
assert!(!llm_signals_tool_intent(
"Here are the results you asked for."
));
assert!(!llm_signals_tool_intent("I found 3 matching files."));
}
#[test]
fn test_llm_signals_tool_intent_quoted_string_in_code_block() {
let text = "The button text should say:\n```\n\"I will create your account\"\n```";
assert!(!llm_signals_tool_intent(text));
}
#[test]
fn test_llm_signals_tool_intent_quoted_string_outside_code_block() {
// Quoted intent phrase in prose should not trigger.
let text = "The button says \"Let me search the database\" to the user.";
assert!(!llm_signals_tool_intent(text));
// But unquoted intent in the same line should still trigger.
let text = "I'll fetch the results for you.";
assert!(llm_signals_tool_intent(text));
}
#[test]
fn test_llm_signals_tool_intent_shadowed_prefix() {
// An earlier non-intent "let me" should not shadow a later real intent.
let text = "Sure, let me think about it. Actually, let me search for the file.";
// "let me think" is an exclusion, so this returns false despite the second "let me search".
assert!(!llm_signals_tool_intent(text));
// But without an exclusion phrase, multiple prefixes should be checked.
let text = "I said let me be clear, then let me fetch the data.";
assert!(llm_signals_tool_intent(text));
}
}
+20
View File
@@ -222,6 +222,8 @@ Work independently to complete this job. Report when done."#,
) -> Result<String, WorkerError> {
let max_iterations = self.config.max_iterations;
let mut last_output = String::new();
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
let mut consecutive_tool_intent_nudges: u32 = 0;
// Load tool definitions
reason_ctx.available_tools = self.tools.tool_definitions().await;
@@ -280,11 +282,28 @@ Work independently to complete this job. Report when done."#,
return Ok(last_output);
}
reason_ctx.messages.push(ChatMessage::assistant(&response));
// Nudge the LLM if it expressed tool intent without calling tools
let signals_intent = !reason_ctx.available_tools.is_empty()
&& crate::llm::llm_signals_tool_intent(&response);
if signals_intent && consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
"LLM expressed tool intent without calling a tool, nudging"
);
reason_ctx
.messages
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
} else if !signals_intent {
consecutive_tool_intent_nudges = 0;
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
if let Some(ref text) = content {
self.post_event(
"message",
@@ -344,6 +363,7 @@ Work independently to complete this job. Report when done."#,
}
}
} else {
consecutive_tool_intent_nudges = 0;
// Execute selected tools
for selection in &selections {
self.post_event(
+132
View File
@@ -390,4 +390,136 @@ mod advanced {
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
// -----------------------------------------------------------------------
// 7. Tool intent nudge — model recovers after nudge
// -----------------------------------------------------------------------
#[tokio::test]
async fn tool_intent_nudge_recovery() {
let trace =
LlmTrace::from_file(format!("{FIXTURES}/tool_intent_nudge_recovery.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Search for the config file.").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
rig.verify_trace_expects(&trace, &responses);
// The nudge should have caused the model to actually call a tool.
let started = rig.tool_calls_started();
assert!(
started.iter().any(|s| s == "echo"),
"expected echo tool call after nudge, got: {started:?}"
);
// Verify the nudge was injected: the TraceLlm request_hint on step 2
// requires "tool_calls mechanism" in the last user message. If the hint
// didn't match, TraceLlm logs a warning but doesn't fail -- so also
// check captured requests directly.
let trace_llm = rig.trace_llm().expect("trace_llm should exist");
assert_eq!(
trace_llm.hint_mismatches(),
0,
"nudge message should have been injected before the tool-call step"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// 8. Tool intent nudge — caps at 2 nudges
// -----------------------------------------------------------------------
#[tokio::test]
async fn tool_intent_nudge_cap() {
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_intent_nudge_cap.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Fetch the project data for me.").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
rig.verify_trace_expects(&trace, &responses);
// Exactly 3 LLM calls: nudge after 1st, nudge after 2nd, 3rd text
// returned as-is (cap of 2 nudges reached).
let trace_llm = rig.trace_llm().expect("trace_llm should exist");
let captured = trace_llm.captured_requests();
assert_eq!(
captured.len(),
3,
"expected exactly 3 LLM calls (2 nudged + 1 returned), got {}",
captured.len()
);
// Verify both nudges fired: calls 2 and 3 should have the nudge
// message as the last user message.
for call_idx in [1usize, 2] {
let msgs = &captured[call_idx];
let last_user = msgs
.iter()
.rev()
.find(|m| matches!(m.role, ironclaw::llm::Role::User));
assert!(
last_user.is_some_and(|m| m.content.contains("tool_calls mechanism")),
"call {} should have the nudge as last user message",
call_idx + 1
);
}
// No tools should have been called (model never issued tool_calls).
let started = rig.tool_calls_started();
assert!(
started.is_empty(),
"no tools should be called when model keeps narrating, got: {started:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// 9. Tool intent nudge — no false positive on conversational "let me explain"
// -----------------------------------------------------------------------
#[tokio::test]
async fn tool_intent_no_false_positive() {
let trace =
LlmTrace::from_file(format!("{FIXTURES}/tool_intent_no_false_positive.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("How does auth work?").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
rig.verify_trace_expects(&trace, &responses);
// "Let me explain" should NOT trigger a nudge, so the TraceLlm should
// have been called exactly once (the text response) with no extra nudge
// messages injected.
let trace_llm = rig.trace_llm().expect("trace_llm should exist");
let captured = trace_llm.captured_requests();
assert_eq!(
captured.len(),
1,
"expected exactly 1 LLM call (no nudge), got {}",
captured.len()
);
// No tools should have been called.
let started = rig.tool_calls_started();
assert!(
started.is_empty(),
"no tools should be called for a conversational response, got: {started:?}"
);
rig.shutdown();
}
}
@@ -0,0 +1,16 @@
{
"model_name": "advanced-tool-intent-no-false-positive",
"steps": [
{
"response": {
"type": "text",
"content": "Let me explain how the authentication system works. It uses JWT tokens with a 24-hour expiry. The job is complete.",
"input_tokens": 50,
"output_tokens": 30
}
}
],
"expects": {
"response_contains": ["authentication"]
}
}
@@ -0,0 +1,34 @@
{
"model_name": "advanced-tool-intent-nudge-cap",
"steps": [
{
"response": {
"type": "text",
"content": "I'll fetch the data right away.",
"input_tokens": 50,
"output_tokens": 10
}
},
{
"request_hint": { "last_user_message_contains": "tool_calls mechanism" },
"response": {
"type": "text",
"content": "I'm going to query the database now.",
"input_tokens": 100,
"output_tokens": 10
}
},
{
"request_hint": { "last_user_message_contains": "tool_calls mechanism" },
"response": {
"type": "text",
"content": "Let me run the search for you.",
"input_tokens": 150,
"output_tokens": 10
}
}
],
"expects": {
"response_contains": ["run the search"]
}
}
@@ -0,0 +1,41 @@
{
"model_name": "advanced-tool-intent-nudge-recovery",
"steps": [
{
"response": {
"type": "text",
"content": "Let me search for that file now.",
"input_tokens": 50,
"output_tokens": 10
}
},
{
"request_hint": { "last_user_message_contains": "tool_calls mechanism" },
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_echo_1",
"name": "echo",
"arguments": { "message": "found it" }
}
],
"input_tokens": 100,
"output_tokens": 15
}
},
{
"response": {
"type": "text",
"content": "I found the file you were looking for. The job is complete.",
"input_tokens": 150,
"output_tokens": 20
}
}
],
"expects": {
"tools_used": ["echo"],
"response_contains": ["found"],
"all_tools_succeeded": true
}
}