Skip to content

Commit 94c1d32

Browse files
committed
feat(search): grep fallback when semantic recall is empty
MemorySearchTool.execute returned [] as soon as semantic search yielded no memories, leaving the agent with no escape hatch — there is no grep tool exposed in the memory toolset (only read/search/ls), so a query that the embedder or index failed on was a dead end even when the literal string exists in stored content. Retry with a literal grep when semantic recall is empty: - pattern = re.escape(query) # match as literal substring, not regex - case_insensitive=True # belt-and-suspenders over query casefold - node_limit = limit + 10 # match the over-sampling search already does grep matches ({line, uri, content}) are projected onto the {uri, score} memory shape (score=0.0 honestly marks "no relevance score, literal match") so optimize_search_result can filter .abstract.md/.overview.md and truncate uniformly. Guarded edges: - empty query skips grep (re.escape('') == '' would match everything) - grep exceptions are swallowed and logged; the tool still returns [] instead of propagating a partial failure This is orthogonal to #2900, which hardens viking_fs.grep itself (VikingDB timeout/empty -> fs). This PR is the Tool-layer complement: when semantic search *as a whole* returns nothing, drop to grep. Tests: tests/unit/test_search_grep_fallback.py (6 cases, all pass) cover results-present (no grep), empty->grep mapping, both-empty, grep raises, empty query, and regex-escaping of metacharacters.
1 parent fdcfa0e commit 94c1d32

2 files changed

Lines changed: 150 additions & 1 deletion

File tree

openviking/session/memory/tools.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import json
10+
import re
1011
from abc import ABC, abstractmethod
1112
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
1213

@@ -268,7 +269,42 @@ async def execute(
268269
limit=limit + 10,
269270
ctx=request_ctx,
270271
)
271-
return optimize_search_result(search_result.to_dict(), limit=limit)
272+
result_dict = search_result.to_dict()
273+
memories = result_dict.get("memories") or []
274+
if memories:
275+
return optimize_search_result(result_dict, limit=limit)
276+
# Semantic recall returned nothing. Before reporting an empty
277+
# result, retry with a literal grep so that an embedding/index gap
278+
# does not masquerade as "no matching content". The query is
279+
# regex-escaped so it matches as a literal substring; case-
280+
# insensitive covers any residual case mismatch. grep returns
281+
# {line, uri, content} matches — project onto the {uri, score}
282+
# memory shape so optimize_search_result can filter/limit uniformly.
283+
if query:
284+
try:
285+
grep_result = await ctx.viking_fs.grep(
286+
target_uri,
287+
pattern=re.escape(query),
288+
case_insensitive=True,
289+
node_limit=limit + 10,
290+
ctx=request_ctx,
291+
)
292+
except Exception as grep_err:
293+
tracer.error(f"search grep fallback failed: {grep_err}")
294+
grep_result = {"matches": []}
295+
grep_memories = [
296+
{
297+
"uri": m.get("uri", ""),
298+
"score": 0.0,
299+
"line": m.get("line", 0),
300+
"content": m.get("content", ""),
301+
}
302+
for m in (grep_result.get("matches") or [])
303+
if m.get("uri")
304+
]
305+
if grep_memories:
306+
result_dict = {"memories": grep_memories}
307+
return optimize_search_result(result_dict, limit=limit)
272308
except Exception as e:
273309
tracer.error(f"Failed to execute search: {e}")
274310
return {"error": str(e)}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
2+
# SPDX-License-Identifier: AGPL-3.0
3+
4+
"""Tests for MemorySearchTool semantic→grep fallback.
5+
6+
Pure unit tests: ctx.viking_fs is mocked, no vectordb/embedder initialized.
7+
"""
8+
9+
from types import SimpleNamespace
10+
from unittest.mock import AsyncMock
11+
12+
import pytest
13+
14+
from openviking.session.memory.tools import MemorySearchTool
15+
16+
17+
def _fake_find_result(memories):
18+
"""Minimal stand-in for FindResult with the .to_dict() shape the tool reads."""
19+
return SimpleNamespace(to_dict=lambda: {"memories": memories})
20+
21+
22+
def _make_ctx(search_return, grep_return=None, grep_raises=None):
23+
viking_fs = SimpleNamespace()
24+
viking_fs.search = AsyncMock(return_value=search_return)
25+
if grep_raises is not None:
26+
viking_fs.grep = AsyncMock(side_effect=grep_raises)
27+
else:
28+
viking_fs.grep = AsyncMock(return_value=grep_return or {"matches": []})
29+
return SimpleNamespace(
30+
viking_fs=viking_fs,
31+
default_search_uris="viking://user/u/memories",
32+
request_ctx=None,
33+
)
34+
35+
36+
async def test_search_with_results_does_not_grep():
37+
"""When semantic search returns memories, grep must NOT be called."""
38+
find = _fake_find_result([{"uri": "viking://user/u/m1.md", "score": 0.9}])
39+
ctx = _make_ctx(find, grep_return={"matches": [{"uri": "should-not-appear"}]})
40+
tool = MemorySearchTool()
41+
42+
result = await tool.execute(ctx, query="anything", limit=10)
43+
44+
assert isinstance(result, list)
45+
assert result and result[0]["uri"].endswith("m1.md")
46+
ctx.viking_fs.grep.assert_not_called()
47+
48+
49+
async def test_search_empty_falls_back_to_grep():
50+
"""Empty semantic recall triggers grep; matches map to {uri, score} memories."""
51+
find = _fake_find_result([])
52+
grep_ret = {
53+
"matches": [
54+
{"uri": "viking://user/u/notes.md", "line": 42, "content": "hermes agent"},
55+
]
56+
}
57+
ctx = _make_ctx(find, grep_return=grep_ret)
58+
tool = MemorySearchTool()
59+
60+
result = await tool.execute(ctx, query="hermes", limit=10)
61+
62+
ctx.viking_fs.grep.assert_awaited_once()
63+
# grep called with regex-escaped pattern, case_insensitive=True
64+
_, kwargs = ctx.viking_fs.grep.call_args
65+
assert kwargs["pattern"] == "hermes" # re.escape("hermes") == "hermes"
66+
assert kwargs["case_insensitive"] is True
67+
assert result and result[0]["uri"].endswith("notes.md")
68+
69+
70+
async def test_search_empty_and_grep_empty_returns_empty_list():
71+
"""Both empty → optimize_search_result([]) → [] (not a dict, not error)."""
72+
find = _fake_find_result([])
73+
ctx = _make_ctx(find, grep_return={"matches": []})
74+
tool = MemorySearchTool()
75+
76+
result = await tool.execute(ctx, query="nothinghere", limit=10)
77+
78+
assert result == []
79+
80+
81+
async def test_grep_exception_does_not_break_search():
82+
"""If grep raises, the tool returns [] instead of propagating."""
83+
find = _fake_find_result([])
84+
ctx = _make_ctx(find, grep_raises=RuntimeError("vikingdb down"))
85+
tool = MemorySearchTool()
86+
87+
result = await tool.execute(ctx, query="x", limit=10)
88+
89+
assert result == []
90+
91+
92+
async def test_empty_query_does_not_grep():
93+
"""Empty query must not trigger grep — re.escape('') == '' matches everything."""
94+
find = _fake_find_result([])
95+
ctx = _make_ctx(find, grep_return={"matches": [{"uri": "viking://user/u/x.md"}]})
96+
tool = MemorySearchTool()
97+
98+
result = await tool.execute(ctx, query="", limit=10)
99+
100+
ctx.viking_fs.grep.assert_not_called()
101+
assert result == []
102+
103+
104+
async def test_grep_pattern_is_regex_escaped():
105+
"""Query with regex metacharacters must be escaped, not interpreted."""
106+
find = _fake_find_result([])
107+
ctx = _make_ctx(find, grep_return={"matches": []})
108+
tool = MemorySearchTool()
109+
110+
await tool.execute(ctx, query="a.b*c+", limit=10)
111+
112+
_, kwargs = ctx.viking_fs.grep.call_args
113+
assert kwargs["pattern"] == r"a\.b\*c\+" # re.escape escapes . * +

0 commit comments

Comments
 (0)