fix(agent): strip leaked [Called tool ...] text from responses (#497)

* fix(agent): strip leaked [Called tool ...] text from agent responses

When the NEAR AI provider flattens tool_call messages to plain text,
markers like [Called tool ...] and [Tool ... returned: ...] can leak
into the user-visible response if the LLM echoes them back. This adds
a sanitization step in the agentic loop's text response path that
strips these internal markers before returning. If stripping leaves
the response empty, a generic fallback message is returned instead.

Closes #487

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

* refactor: use fold instead of collect+join to avoid heap allocation

Address review feedback: replace Vec collect + join with fold to build
the filtered string directly, avoiding an intermediate heap allocation.

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

* style: cargo fmt

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Pierre LE GUEN <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-04 18:16:26 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 Pierre LE GUEN
parent e4e78d8a87
commit 89600e2b5c
+65 -1
View File
@@ -291,7 +291,11 @@ impl Agent {
match output.result {
RespondResult::Text(text) => {
return Ok(AgenticLoopResult::Response(text));
// 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.
let sanitized = strip_internal_tool_call_text(&text);
return Ok(AgenticLoopResult::Response(sanitized));
}
RespondResult::ToolCalls {
tool_calls,
@@ -900,6 +904,38 @@ fn compact_messages_for_retry(messages: &[ChatMessage]) -> Vec<ChatMessage> {
compacted
}
/// Strip internal `[Called tool ...]` and `[Tool ... returned: ...]` markers
/// from a response string. These markers are inserted by provider-level message
/// flattening (e.g. NEAR AI) and can leak into the user-visible response when
/// the LLM echoes them back.
fn strip_internal_tool_call_text(text: &str) -> String {
// Remove lines that are purely internal tool-call markers.
// Pattern: lines matching `[Called tool <name>(...)]` or `[Tool <name> returned: ...]`
let result = text
.lines()
.filter(|line| {
let trimmed = line.trim();
!((trimmed.starts_with("[Called tool ") && trimmed.ends_with(']'))
|| (trimmed.starts_with("[Tool ")
&& trimmed.contains(" returned:")
&& trimmed.ends_with(']')))
})
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push('\n');
}
acc.push_str(s);
acc
});
let result = result.trim();
if result.is_empty() {
"I wasn't able to complete that request. Could you try rephrasing or providing more details?".to_string()
} else {
result.to_string()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
@@ -1902,4 +1938,32 @@ mod tests {
}
}
}
#[test]
fn test_strip_internal_tool_call_text_removes_markers() {
let input = "[Called tool search({\"query\": \"test\"})]\nHere is the answer.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, "Here is the answer.");
}
#[test]
fn test_strip_internal_tool_call_text_removes_returned_markers() {
let input = "[Tool search returned: some result]\nSummary of findings.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, "Summary of findings.");
}
#[test]
fn test_strip_internal_tool_call_text_all_markers_yields_fallback() {
let input = "[Called tool search({\"query\": \"test\"})]\n[Tool search returned: error]";
let result = super::strip_internal_tool_call_text(input);
assert!(result.contains("wasn't able to complete"));
}
#[test]
fn test_strip_internal_tool_call_text_preserves_normal_text() {
let input = "This is a normal response with [brackets] inside.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, input);
}
}