mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
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:
1 parent
97914b16ae
commit
d8cc9542f6
2 files changed
+66
-13
No files matched your search
+33
-13
@@ -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.")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user