Merge pull request #12104 from chuiwenwei/feat/memory-omitted-matches

feat(memory): report omitted matches on the memory tool paths
This commit is contained in:
YHH authored and GitHub committed 2026-10-09 03:03:47 -07:00
commit 6a822a2e20
4 files changed
+184 -11

No files matched your search

+1 -1
View File
@@ -437,7 +437,7 @@ func buildRecallBlock(hits []RecallHit, budget, omittedByLimit int) ([]RecallHit
}
block := prefix + strings.Join(entries, "")
if total := omittedByLimit + omittedByBudget; total > 0 {
note := fmt.Sprintf("- omitted=%d additional relevant fact(s) because of the recall limit or character budget\n", total)
note := omittedNote(total, "relevant fact(s)", "the recall limit or character budget") + "\n"
if utf8.RuneCountInString(block+note+close) <= budget {
block += note
}
+1 -1
View File
@@ -50,7 +50,7 @@ func TestExpiryIsAHardBoundaryExcludedFromRecall(t *testing.T) {
t.Fatalf("expired facts must never be auto-recalled: %+v", result.Hits)
}
// Explicit search still finds it — expiry hides nothing from management.
hits, err := searchMemories(t.Context(), store, "release branch", "", "", 8)
hits, _, err := searchMemories(t.Context(), store, "release branch", "", "", 8)
if err != nil || len(hits) != 1 {
t.Fatalf("explicit search should still see expired facts, got %v %v", hits, err)
}
+37 -9
View File
@@ -72,11 +72,11 @@ func (t recallTool) Execute(ctx context.Context, args json.RawMessage) (string,
limit := clampRecallLimit(in.Limit)
switch strings.TrimSpace(in.Operation) {
case "search":
hits, err := searchMemories(ctx, t.store, in.Query, memType, memScope, limit)
hits, omitted, err := searchMemories(ctx, t.store, in.Query, memType, memScope, limit)
if err != nil {
return "", err
}
return formatMemoryHits(in.Query, hits), nil
return formatMemoryHits(in.Query, hits, omitted), nil
case "read":
m, ok := readMemoryByName(t.store, in.Name)
if !ok {
@@ -107,20 +107,20 @@ type memoryDoc struct {
length int
}
func searchMemories(ctx context.Context, store Store, query string, typ Type, scope FactScope, limit int) ([]memoryHit, error) {
func searchMemories(ctx context.Context, store Store, query string, typ Type, scope FactScope, limit int) ([]memoryHit, int, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("query is required")
return nil, 0, fmt.Errorf("query is required")
}
queryTerms, err := retrieval.QueryTerms(query)
if err != nil {
return nil, err
return nil, 0, err
}
memories := filterMemories(store.ListAll(), typ, scope)
docs := make([]memoryDoc, 0, len(memories))
for _, m := range memories {
if err := ctx.Err(); err != nil {
return nil, err
return nil, 0, err
}
text := memorySearchText(m)
terms := retrieval.Tokens(text)
@@ -135,7 +135,7 @@ func searchMemories(ctx context.Context, store Store, query string, typ Type, sc
})
}
if len(docs) == 0 {
return nil, nil
return nil, 0, nil
}
counts := make([]map[string]int, 0, len(docs))
totalLen := 0
@@ -164,13 +164,15 @@ func searchMemories(ctx context.Context, store Store, query string, typ Type, sc
}
return hits[i].Score > hits[j].Score
})
ranked := len(hits)
hits = retrieval.KeepTopRelativeScore(hits, recallScoreFloor, func(hit memoryHit) float64 {
return hit.Score
})
omitted := countOmitted(ranked, len(hits), limit)
if len(hits) > limit {
hits = hits[:limit]
}
return hits, nil
return hits, omitted, nil
}
func recallTypeFilter(s string) (Type, error) {
@@ -225,8 +227,10 @@ func memorySearchText(m Memory) string {
}, "\n")
}
func formatMemoryHits(query string, hits []memoryHit) string {
func formatMemoryHits(query string, hits []memoryHit, omitted int) string {
if len(hits) == 0 {
// KeepTopRelativeScore always keeps the top hit, so an empty result means
// nothing matched; the omission note below only belongs to a real result.
return strings.Join([]string{
"No saved memories matched " + strconvQuote(query) + ".",
"",
@@ -243,6 +247,9 @@ func formatMemoryHits(query string, hits []memoryHit) string {
fmt.Fprintf(&b, "\n%d. score=%.3f id=%s revision=%d name=%s scope=%s type=%s title=%s\n reference: %s\n description: %s\n snippet: %s\n",
i+1, hit.Score, m.ID, m.Revision, m.Name, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), displayTitle(m.Title, m.Name), providerMemoryReference(m), oneLine(m.Description), hit.Snippet)
}
if omitted > 0 {
fmt.Fprintf(&b, "\n%s\n", omittedNote(omitted, "fact(s)", "the result limit or the relevance floor"))
}
b.WriteString("\nUse operation=\"read\" with a stable memory id to inspect the full saved fact.")
return strings.TrimSpace(b.String())
}
@@ -266,7 +273,9 @@ func formatMemoryList(_ Store, memories []Memory, limit int) string {
if len(memories) == 0 {
return "No saved memories found."
}
omitted := 0
if len(memories) > limit {
omitted = len(memories) - limit
memories = memories[:limit]
}
var b strings.Builder
@@ -275,9 +284,28 @@ func formatMemoryList(_ Store, memories []Memory, limit int) string {
fmt.Fprintf(&b, "- [%s](%s.md) reference=%s id=%s revision=%d scope=%s type=%s - %s\n",
displayTitle(m.Title, m.Name), m.Name, providerMemoryReference(m), m.ID, m.Revision, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), oneLine(m.Description))
}
if omitted > 0 {
fmt.Fprintf(&b, "%s\n", omittedNote(omitted, "fact(s)", "the list limit"))
}
return strings.TrimSpace(b.String())
}
// countOmitted reports what a ranked result dropped: the matches the relative
// score floor compacted away, plus the tail the caller's limit cut off.
func countOmitted(ranked, kept, limit int) int {
omitted := ranked - kept
if kept > limit {
omitted += kept - limit
}
return omitted
}
// omittedNote is the one place the omission line is spelled, so a tool result and
// the automatic recall block cannot drift apart (#11853).
func omittedNote(omitted int, noun, because string) string {
return fmt.Sprintf("- omitted=%d additional %s because of %s", omitted, noun, because)
}
func providerMemoryReference(m Memory) string {
return string(NormalizeFactScope(string(m.Scope))) + "/" + slug(m.Name) + ".md"
}
+145
View File
@@ -3,6 +3,8 @@ package memory
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
@@ -324,3 +326,146 @@ func saveMemory(t *testing.T, store Store, m Memory) {
t.Fatalf("Save(%s): %v", m.Name, err)
}
}
func TestRecallToolReportsOmittedMatches(t *testing.T) {
store := Store{Dir: testenv.TempDir(t)}
const facts = 12
for i := range facts {
saveMemory(t, store, Memory{
Name: fmt.Sprintf("omitted-search-%02d", i),
Description: "omitted search fixture",
Type: TypeProject,
Body: "omittedsearchterm",
})
}
tl := NewRecallTool(store)
// More matches than the caller asked for: the model must be able to tell a
// truncated result from a complete one, the way the automatic recall block
// already reports what it dropped.
out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"omittedsearchterm","limit":5}`))
if err != nil {
t.Fatalf("Execute search: %v", err)
}
omitted, ok := omittedFromOutput(out)
if !ok {
t.Fatalf("search did not report what it dropped:\n%s", out)
}
if omitted != facts-5 {
t.Fatalf("omitted=%d, want exactly %d (the floor kept all twelve, the limit cut five):\n%s", omitted, facts-5, out)
}
// The negative control: a result that lost nothing must not claim otherwise.
full, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"omittedsearchterm","limit":20}`))
if err != nil {
t.Fatalf("Execute search: %v", err)
}
if strings.Contains(full, "omitted=") {
t.Fatalf("a complete search reported an omission:\n%s", full)
}
}
func TestRecallToolReportsFloorDrops(t *testing.T) {
store := Store{Dir: testenv.TempDir(t)}
saveMemory(t, store, Memory{
Name: "rare-floor-rule",
Description: "Rare floor rule",
Type: TypeProject,
Body: "rarefloorterm common common common",
})
for i := range 12 {
saveMemory(t, store, Memory{
Name: fmt.Sprintf("floor-note-%02d", i),
Description: "Common note",
Type: TypeProject,
Body: "common",
})
}
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rarefloorterm common","limit":20}`))
if err != nil {
t.Fatalf("Execute search: %v", err)
}
// Nothing was dropped by the limit, so this note can only come from the
// relevance floor: the drop the tool used to make silently.
if omitted, ok := omittedFromOutput(out); !ok || omitted != 12 {
t.Fatalf("floor drops not reported (omitted=%d ok=%v):\n%s", omitted, ok, out)
}
}
func TestRecallToolReportsOmittedListEntries(t *testing.T) {
store := Store{Dir: testenv.TempDir(t)}
for i := range 12 {
saveMemory(t, store, Memory{
Name: fmt.Sprintf("omitted-list-%02d", i),
Description: "omitted list fixture",
Type: TypeProject,
Body: "listed fact",
})
}
tl := NewRecallTool(store)
out, err := tl.Execute(context.Background(), []byte(`{"operation":"list","limit":5}`))
if err != nil {
t.Fatalf("Execute list: %v", err)
}
if omitted, ok := omittedFromOutput(out); !ok || omitted != 7 {
t.Fatalf("list omitted=%d ok=%v, want 7:\n%s", omitted, ok, out)
}
full, err := tl.Execute(context.Background(), []byte(`{"operation":"list","limit":20}`))
if err != nil {
t.Fatalf("Execute list: %v", err)
}
if strings.Contains(full, "omitted=") {
t.Fatalf("a complete list reported an omission:\n%s", full)
}
}
// omittedFromOutput reads the tool's omission note, in the same shape the
// automatic recall block uses: "- omitted=N ...".
func omittedFromOutput(out string) (int, bool) {
_, rest, ok := strings.Cut(out, "- omitted=")
if !ok {
return 0, false
}
digits, _, _ := strings.Cut(rest, " ")
n, err := strconv.Atoi(digits)
if err != nil {
return 0, false
}
return n, true
}
func TestCountOmittedCountsBothReasons(t *testing.T) {
cases := []struct {
name string
ranked, kept, limit int
want int
}{
{"nothing dropped", 3, 3, 8, 0},
{"floor only", 13, 1, 8, 12},
{"limit only", 12, 12, 5, 7},
{"floor and limit", 20, 12, 5, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := countOmitted(tc.ranked, tc.kept, tc.limit); got != tc.want {
t.Fatalf("countOmitted(%d, %d, %d) = %d, want %d", tc.ranked, tc.kept, tc.limit, got, tc.want)
}
})
}
}
func TestOmittedNoteIsTheRecallBlockWording(t *testing.T) {
// The recall block and the tool paths must spell the omission line the same
// way; this pins the one spelling both now call.
got := omittedNote(3, "relevant fact(s)", "the recall limit or character budget")
want := "- omitted=3 additional relevant fact(s) because of the recall limit or character budget"
if got != want {
t.Fatalf("omittedNote = %q, want %q", got, want)
}
if !strings.Contains(got, "- omitted=3 ") {
t.Fatalf("omittedNote lost the count-first shape: %q", got)
}
}