fix(agent): also strip bare <invoke> tool-call blocks (no wrapper) (#1261)

Follow-up to #1260. A model (notably the claude-cli proxy under a
degraded session) sometimes emits a tool call as a bare
<invoke name="...">...</invoke> block with no <function_calls> wrapper.
fullToolCallBlockPattern only matched the wrapped form, so the bare
block slipped through to the tag-only strip, leaking the inner
<parameter> command text into the reply while the tool never ran.

Match and remove complete bare <invoke> blocks too (after the wrapped
form), add "<invoke name=" as a detection indicator, and log the
dropped tool name(s). Partial/unterminated artifacts still fall through
to the existing tag strip.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zezae OhandClaude Opus 4.8 authored and GitHub committed 2026-06-23 09:08:07 +07:00
1 parent 97914b16ae
commit d8cc9542f6
2 files changed
+66 -13

No files matched your search

+33 -13
View File
@@ -96,9 +96,20 @@ var fullToolCallBlockPattern = regexp.MustCompile(
// dropped text-encoded tool call can be logged with the tool it tried to call.
var invokeNamePattern = regexp.MustCompile(`(?i)<invoke\s+name="([^"]+)"`)
// bareInvokeBlockPattern matches a complete `<invoke name="...">...</invoke>`
// block emitted as text WITHOUT the surrounding <function_calls> wrapper. Some
// models — and the claude-cli proxy under a degraded session — drop the wrapper
// and emit just the invoke block, which fullToolCallBlockPattern misses; the
// tag-only strip would then leak the inner <parameter> text. Applied after the
// wrapped form so wrapper-nested invokes are already gone.
var bareInvokeBlockPattern = regexp.MustCompile(
`(?is)<invoke\s+name="[^"]*".*?</invoke>`,
)
var garbledToolXMLIndicators = []string{
"invfunction_calls",
"functioninvoke",
"<invoke name=",
"<parameter name=",
"</parameter",
"<function_call",
@@ -122,26 +133,35 @@ func stripGarbledToolXML(content string) string {
original := content
// A COMPLETE <function_calls>...</function_calls> block is not "garble" — it
// is a tool call the model wrote as TEXT instead of invoking it natively. This
// shows up with the claude-cli thin-proxy provider, where tool execution lives
// inside the CLI: when the model emits the call as text the tool never runs,
// and the tag-only strip below would leave the inner <parameter> values
// mangled into the reply. Remove whole blocks and log the attempted tool
// name(s) at WARN so this otherwise-silent no-op is diagnosable.
if blocks := fullToolCallBlockPattern.FindAllString(content, -1); len(blocks) > 0 {
tools := make([]string, 0, len(blocks))
// A COMPLETE tool-call block is not "garble" — it is a tool call the model
// wrote as TEXT instead of invoking it natively. This shows up with the
// claude-cli thin-proxy provider, where tool execution lives inside the CLI:
// when the model emits the call as text the tool never runs, and the tag-only
// strip below would leave the inner <parameter> values mangled into the reply.
// Remove whole blocks — <function_calls>...</function_calls> wrappers first,
// then any bare <invoke>...</invoke> left without a wrapper — and log the
// attempted tool name(s) at WARN so this otherwise-silent no-op is diagnosable.
var droppedTools []string
var droppedBlocks int
for _, re := range []*regexp.Regexp{fullToolCallBlockPattern, bareInvokeBlockPattern} {
blocks := re.FindAllString(content, -1)
if len(blocks) == 0 {
continue
}
droppedBlocks += len(blocks)
for _, b := range blocks {
for _, m := range invokeNamePattern.FindAllStringSubmatch(b, -1) {
tools = append(tools, m[1])
droppedTools = append(droppedTools, m[1])
}
}
content = re.ReplaceAllString(content, "")
}
if droppedBlocks > 0 {
slog.Warn("dropped text-encoded tool call from response",
"tools", tools,
"blocks", len(blocks),
"tools", droppedTools,
"blocks", droppedBlocks,
"hint", "model wrote a tool call as text instead of invoking it; the tool did not run",
)
content = fullToolCallBlockPattern.ReplaceAllString(content, "")
}
// Strip any remaining stray tags (partial DeepSeek/GLM/Minimax artifacts).
@@ -36,6 +36,20 @@ func TestStripGarbledToolXML_FullToolCallBlock(t *testing.T) {
input: "Result here <tool_call>oops",
want: "Result here oops",
},
{
name: "removes bare <invoke> block without function_calls wrapper",
input: "Collecting PRs.\n\n" +
"<invoke name=\"mcp__goclaw-bridge__exec\">\n" +
"<parameter name=\"command\">gh pr view 1218</parameter>\n" +
"</invoke>",
want: "Collecting PRs.",
},
{
name: "bare invoke-only response collapses to empty",
input: "<invoke name=\"mcp__goclaw-bridge__message\">" +
"<parameter name=\"text\">hi</parameter></invoke>",
want: "",
},
}
for _, tt := range tests {
@@ -66,3 +80,22 @@ func TestSanitizeAssistantContent_NoToolCallArgumentLeak(t *testing.T) {
t.Errorf("SanitizeAssistantContent() = %q, want %q", got, "Done.")
}
}
// Regression: claude-cli under a degraded session emits a tool call as a BARE
// <invoke> block with no <function_calls> wrapper. The whole block must be
// removed so the command argument does not leak into the user-facing reply.
func TestSanitizeAssistantContent_BareInvokeNoWrapper(t *testing.T) {
input := "Format error, retrying.\n\n" +
"<invoke name=\"mcp__goclaw-bridge__exec\">\n" +
"<parameter name=\"command\">( cd ~/secret-path && gh pr view 1218 )</parameter>\n" +
"</invoke>"
got := SanitizeAssistantContent(input)
if strings.Contains(got, "secret-path") || strings.Contains(got, "gh pr view") {
t.Errorf("bare-invoke command argument leaked into reply: %q", got)
}
if got != "Format error, retrying." {
t.Errorf("SanitizeAssistantContent() = %q, want %q", got, "Format error, retrying.")
}
}