From 8a344271743d11bca13fce2a7d28dd5787d955dd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 3 Aug 2026 05:51:34 +0530 Subject: [PATCH] fix(client): reject brace-match tool calls with trailing prose --- client/core/stream.go | 8 +++++++ client/hermes_toolcall_test.go | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/client/core/stream.go b/client/core/stream.go index aebc191..2200881 100644 --- a/client/core/stream.go +++ b/client/core/stream.go @@ -769,6 +769,14 @@ func parseBraceMatchToolCall(text string) (cleanText string, toolCalls []ToolCal if end <= start { return text, nil } + // The JSON object must run to the end of the response. Trailing prose + // after a JSON-shaped snippet is a strong signal the snippet is an + // example or explanation (e.g. inside a code fence, or mid-sentence), + // not an actual tool call — executing such a false positive would be + // surprising and potentially destructive. + if strings.TrimSpace(text[end+1:]) != "" { + return text, nil + } candidate := text[start : end+1] if tc, ok := parseHermesCall(candidate, 0); ok { return strings.TrimSpace(text[:start]), []ToolCall{tc} diff --git a/client/hermes_toolcall_test.go b/client/hermes_toolcall_test.go index 2380651..959fc6c 100644 --- a/client/hermes_toolcall_test.go +++ b/client/hermes_toolcall_test.go @@ -181,3 +181,43 @@ func TestParseBraceMatch_NoJSONInText(t *testing.T) { t.Errorf("clean = %q, want original text", clean) } } + +func TestParseBraceMatch_JSONExampleInProseNotExecuted(t *testing.T) { + t.Parallel() + // A tool-call-shaped JSON snippet embedded mid-sentence as an example + // must NOT be executed as a tool call (false-positive guard). + text := `The user asked me to look up data. {"name":"search","arguments":{"q":"x"}} is the format I would use.` + clean, calls := ParseInlineToolCalls(text) + if len(calls) != 0 { + t.Errorf("expected 0 calls for JSON example in prose, got %d: %+v", len(calls), calls) + } + if clean != text { + t.Errorf("clean = %q, want original text verbatim", clean) + } +} + +func TestParseBraceMatch_FencedJSONExampleNotExecuted(t *testing.T) { + t.Parallel() + // A JSON example inside a code fence must not be executed. + text := "Call tools like this:\n```json\n{\"name\":\"Bash\",\"arguments\":{\"command\":\"ls\"}}\n```" + clean, calls := ParseInlineToolCalls(text) + if len(calls) != 0 { + t.Errorf("expected 0 calls for fenced JSON example, got %d: %+v", len(calls), calls) + } + if clean != text { + t.Errorf("clean = %q, want original text verbatim", clean) + } +} + +func TestParseBraceMatch_TrailingWhitespaceStillParsed(t *testing.T) { + t.Parallel() + // A bare JSON call with trailing whitespace/newlines is a real call. + text := "{\"name\":\"search\",\"arguments\":{\"q\":\"golang\"}}\n\n" + clean, calls := ParseInlineToolCalls(text) + if len(calls) != 1 || calls[0].Name != "search" { + t.Fatalf("expected 1 call named search, got %+v", calls) + } + if clean != "" { + t.Errorf("clean = %q, want empty", clean) + } +}