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.") + } +}