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