diff --git a/internal/agent/sanitize.go b/internal/agent/sanitize.go
index fcb68a11..3eca1c61 100644
--- a/internal/agent/sanitize.go
+++ b/internal/agent/sanitize.go
@@ -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)...`
+// block emitted as text WITHOUT the surrounding 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 text. Applied after the
+// wrapped form so wrapper-nested invokes are already gone.
+var bareInvokeBlockPattern = regexp.MustCompile(
+ `(?is)`,
+)
+
var garbledToolXMLIndicators = []string{
"invfunction_calls",
"functioninvoke",
+ "... 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 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 values mangled into the reply.
+ // Remove whole blocks — ... wrappers first,
+ // then any bare ... 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).
diff --git a/internal/agent/sanitize_toolcall_xml_test.go b/internal/agent/sanitize_toolcall_xml_test.go
index e3513617..4381c1ba 100644
--- a/internal/agent/sanitize_toolcall_xml_test.go
+++ b/internal/agent/sanitize_toolcall_xml_test.go
@@ -36,6 +36,20 @@ func TestStripGarbledToolXML_FullToolCallBlock(t *testing.T) {
input: "Result here oops",
want: "Result here oops",
},
+ {
+ name: "removes bare block without function_calls wrapper",
+ input: "Collecting PRs.\n\n" +
+ "\n" +
+ "gh pr view 1218\n" +
+ "",
+ want: "Collecting PRs.",
+ },
+ {
+ name: "bare invoke-only response collapses to empty",
+ input: "" +
+ "hi",
+ 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
+// block with no 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" +
+ "\n" +
+ "( cd ~/secret-path && gh pr view 1218 )\n" +
+ ""
+
+ 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.")
+ }
+}