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!(