From 4911962ae7f9455f64229c59fb5f5c72b2e814e2 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 16:08:27 +0800 Subject: [PATCH] fix(agent): subgoal_track no longer matches keywords against tool names (#2758) --- internal/agent/subgoal_track.go | 10 +++++-- internal/agent/zz_issue2758_test.go | 41 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 internal/agent/zz_issue2758_test.go diff --git a/internal/agent/subgoal_track.go b/internal/agent/subgoal_track.go index 8732cfd72..905830169 100644 --- a/internal/agent/subgoal_track.go +++ b/internal/agent/subgoal_track.go @@ -187,13 +187,19 @@ func (s *subgoalState) recordToolCall(toolName, args string) { return } argLower := strings.ToLower(args) - toolLower := strings.ToLower(toolName) for i := range s.subgoals { if s.subgoals[i].addressed { continue } for _, kw := range s.subgoals[i].keywords { - if strings.Contains(argLower, kw) || strings.Contains(toolLower, kw) { + // #2758: match keywords against the tool ARGUMENTS only. The old + // `|| strings.Contains(toolLower, kw)` matched tool NAMES - any + // read_file whitewashed a "file" keyword, search_files a "search" + // keyword - so unrelated calls marked subgoals addressed and the + // un-addressed-subgoal warning went silent (systematic + // under-reporting). Tool names are generic verbs/nouns by design + // and carry no evidence about WHICH subgoal was addressed. + if strings.Contains(argLower, kw) { s.subgoals[i].addressed = true break } diff --git a/internal/agent/zz_issue2758_test.go b/internal/agent/zz_issue2758_test.go new file mode 100644 index 000000000..b8f016ea4 --- /dev/null +++ b/internal/agent/zz_issue2758_test.go @@ -0,0 +1,41 @@ +package agent + +// Issue #2758 probe: recordToolCall matched subgoal keywords against the +// TOOL NAME with substring Contains - "read_file" whitewashed a "file" +// keyword, "search_files" whitewashed "search"... any unrelated call +// marked the subgoal addressed and the un-addressed-subgoal warning went +// silent (systematic under-reporting). The fix: keywords may only match in +// the tool ARGUMENTS; tool-name matching is removed entirely. + +import "testing" + +func TestIssue2758ToolNameSubstringNoLongerWhitewashes(t *testing.T) { + s := newSubgoalState() + s.recordAssistantText("Plan:\n1. Modify the config file\n2. Update database schema\n3. Refactor payment service", 1) + // Same reproduction as the issue: an unrelated read of /tmp/README.md + // used to mark subgoal 1 ("file") addressed via the tool name + // "read_file". + s.recordToolCall("read_file", `{"path": "/tmp/README.md"}`) + for i, sg := range s.subgoals { + if sg.addressed { + t.Fatalf("subgoal %d whitewashed by tool-name substring (kw=%v)", i+1, sg.keywords) + } + } + // And the warning must still fire: neither subgoal was truly addressed. + if w := s.maybeWarn(10); w == "" { + t.Fatal("unaddressed-subgoal warning silenced by tool-name whitewash") + } +} + +func TestIssue2758ArgumentSideStillMatches(t *testing.T) { + s := newSubgoalState() + s.recordAssistantText("Plan:\n1. Modify the config file\n2. Update database schema\n3. Refactor payment service", 1) + // A real edit whose arguments name the config file still counts. + s.recordToolCall("edit_file", `{"file_path": "/app/config.go", "old_text": "x", "new_text": "y"}`) + if !s.subgoals[0].addressed { + t.Fatal("argument-side match must keep marking the subgoal addressed") + } + if s.subgoals[1].addressed || s.subgoals[2].addressed { + t.Fatal("subgoals 2/3 must stay unaddressed (nothing touched the schema or payment)") + } +}