mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(openviking): align session context with shared profile contract
This commit is contained in:
parent
11c1ca01c5
commit
8af2133009
3 changed files with 408 additions and 272 deletions
|
|
@ -82,7 +82,7 @@ _SYNC_TRACE_ENV = "HERMES_OPENVIKING_SYNC_TRACE"
|
|||
_DEFAULT_RECALL_LIMIT = 6
|
||||
_DEFAULT_RECALL_SCORE_THRESHOLD = 0.15
|
||||
_DEFAULT_RECALL_MAX_INJECTED_CHARS = 4000
|
||||
_DEFAULT_PROFILE_MAX_CHARS = 4000
|
||||
_DEFAULT_PROFILE_TOKEN_BUDGET = 6000
|
||||
_DEFAULT_RECALL_TIMEOUT_SECONDS = 4.0
|
||||
_DEFAULT_RECALL_REQUEST_TIMEOUT_SECONDS = 3.0
|
||||
_DEFAULT_RECALL_FULL_READ_LIMIT = 2
|
||||
|
|
@ -90,12 +90,15 @@ _RECALL_QUERY_MIN_CHARS = 5
|
|||
_RECALL_MIN_TIMEOUT_SECONDS = 0.05
|
||||
_READ_BATCH_LIMIT = 3
|
||||
_READ_BATCH_FULL_LIMIT = 2500
|
||||
_PROFILE_READS = (
|
||||
("viking://user/memories/profile.md", "full"),
|
||||
)
|
||||
_PREFERENCES_OVERVIEW_URI = "viking://user/memories/preferences/"
|
||||
_ENTITIES_OVERVIEW_URI = "viking://user/memories/entities/"
|
||||
_SESSION_START_TRUNCATION_SUFFIX = "[...] truncated"
|
||||
_PROFILE_URI = "viking://user/memories/profile.md"
|
||||
_PREFERENCES_URI = "viking://user/memories/preferences"
|
||||
_ENTITIES_URI = "viking://user/memories/entities"
|
||||
_SESSION_START_LIST_PARAMS = {
|
||||
"output": "agent",
|
||||
"recursive": True,
|
||||
"abs_limit": 512,
|
||||
"node_limit": 512,
|
||||
}
|
||||
|
||||
# Maps the viking_remember `category` enum to a viking:// subdirectory.
|
||||
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
|
||||
|
|
@ -1924,10 +1927,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
"env_var": "OPENVIKING_RECALL_MAX_INJECTED_CHARS",
|
||||
},
|
||||
{
|
||||
"key": "profile_max_chars",
|
||||
"description": "Maximum session-start memory characters injected",
|
||||
"default": _DEFAULT_PROFILE_MAX_CHARS,
|
||||
"env_var": "OPENVIKING_PROFILE_MAX_CHARS",
|
||||
"key": "profile_token_budget",
|
||||
"description": "Maximum session-start memory tokens injected",
|
||||
"default": _DEFAULT_PROFILE_TOKEN_BUDGET,
|
||||
"env_var": "OPENVIKING_PROFILE_TOKEN_BUDGET",
|
||||
},
|
||||
{
|
||||
"key": "recall_timeout_seconds",
|
||||
|
|
@ -2969,11 +2972,11 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
"resources": self._env_bool("OPENVIKING_RECALL_RESOURCES", False),
|
||||
}
|
||||
|
||||
def _profile_max_chars(self) -> int:
|
||||
def _profile_token_budget(self) -> int:
|
||||
return self._env_int(
|
||||
"OPENVIKING_PROFILE_MAX_CHARS",
|
||||
_DEFAULT_PROFILE_MAX_CHARS,
|
||||
minimum=200,
|
||||
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
||||
_DEFAULT_PROFILE_TOKEN_BUDGET,
|
||||
minimum=500,
|
||||
maximum=50000,
|
||||
)
|
||||
|
||||
|
|
@ -2987,215 +2990,274 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_placeholder_overview(content: str) -> bool:
|
||||
normalized = " ".join((content or "").strip().lower().split())
|
||||
return normalized in {
|
||||
"[directory overview is not ready]",
|
||||
"[directory overview is not generated]",
|
||||
"[directory abstract is not ready]",
|
||||
"[directory abstract is not generated]",
|
||||
} or normalized.endswith((
|
||||
"[directory overview is not ready]",
|
||||
"[directory overview is not generated]",
|
||||
"[directory abstract is not ready]",
|
||||
"[directory abstract is not generated]",
|
||||
))
|
||||
def _extract_memory_listing(resp: Any) -> List[Dict[str, str]]:
|
||||
result = OpenVikingMemoryProvider._unwrap_result(resp)
|
||||
if not isinstance(result, list):
|
||||
return []
|
||||
|
||||
entries: List[Dict[str, str]] = []
|
||||
for raw in result:
|
||||
if not isinstance(raw, dict) or raw.get("isDir"):
|
||||
continue
|
||||
name = str(raw.get("rel_path") or raw.get("name") or "").strip()
|
||||
if not name.endswith(".md"):
|
||||
continue
|
||||
abstract = " ".join(str(raw.get("abstract") or "").split())[:200]
|
||||
entries.append({"name": name, "abstract": abstract})
|
||||
entries.sort(key=lambda entry: entry["name"])
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _weighted_context_len(content: str) -> int:
|
||||
total = 0
|
||||
for ch in content:
|
||||
total += 2 if ord(ch) >= 0x3000 else 1
|
||||
return total
|
||||
def _token_units(content: str) -> int:
|
||||
"""Return quarter-token units using the shared OpenViking estimator."""
|
||||
return sum(6 if ord(ch) >= 0x3000 else 1 for ch in content)
|
||||
|
||||
@classmethod
|
||||
def _take_weighted_prefix(cls, content: str, max_chars: int) -> str:
|
||||
if max_chars <= 0:
|
||||
def _estimate_tokens(cls, content: str) -> int:
|
||||
units = cls._token_units(content)
|
||||
return (units + 3) // 4
|
||||
|
||||
@classmethod
|
||||
def _take_token_prefix(cls, content: str, max_units: int) -> str:
|
||||
if max_units <= 0:
|
||||
return ""
|
||||
used = 0
|
||||
end = 0
|
||||
for end, ch in enumerate(content):
|
||||
used += 2 if ord(ch) >= 0x3000 else 1
|
||||
if used > max_chars:
|
||||
return content[:end]
|
||||
for index, ch in enumerate(content):
|
||||
used += 6 if ord(ch) >= 0x3000 else 1
|
||||
if used > max_units:
|
||||
return content[:index]
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def _take_weighted_suffix(cls, content: str, max_chars: int) -> str:
|
||||
if max_chars <= 0:
|
||||
def _take_token_suffix(cls, content: str, max_units: int) -> str:
|
||||
if max_units <= 0:
|
||||
return ""
|
||||
used = 0
|
||||
start = len(content)
|
||||
for idx in range(len(content) - 1, -1, -1):
|
||||
ch = content[idx]
|
||||
used += 2 if ord(ch) >= 0x3000 else 1
|
||||
if used > max_chars:
|
||||
used += 6 if ord(ch) >= 0x3000 else 1
|
||||
if used > max_units:
|
||||
return content[start:]
|
||||
start = idx
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def _truncate_text_content(cls, content: str, max_chars: int) -> str:
|
||||
def _truncate_profile_content(cls, content: str, max_units: int) -> str:
|
||||
content = content.strip()
|
||||
if cls._weighted_context_len(content) <= max_chars:
|
||||
if cls._token_units(content) <= max_units:
|
||||
return content
|
||||
suffix = f"\n\n{_SESSION_START_TRUNCATION_SUFFIX}"
|
||||
suffix_len = cls._weighted_context_len(suffix)
|
||||
if max_chars <= suffix_len:
|
||||
return cls._take_weighted_prefix(content, max_chars)
|
||||
return cls._take_weighted_prefix(content, max_chars - suffix_len).rstrip() + suffix
|
||||
|
||||
@classmethod
|
||||
def _truncate_profile_content(cls, content: str, max_chars: int) -> str:
|
||||
content = content.strip()
|
||||
if cls._weighted_context_len(content) <= max_chars:
|
||||
return content
|
||||
marker = f"\n\n{_SESSION_START_TRUNCATION_SUFFIX}\n\n"
|
||||
marker_len = cls._weighted_context_len(marker)
|
||||
if max_chars <= marker_len + 40:
|
||||
return cls._truncate_text_content(content, max_chars)
|
||||
remaining = max_chars - marker_len
|
||||
head_budget = max(1, remaining // 2)
|
||||
tail_budget = max(1, remaining - head_budget)
|
||||
head = cls._take_weighted_prefix(content, head_budget).rstrip()
|
||||
tail = cls._take_weighted_suffix(content, tail_budget).lstrip()
|
||||
if not head or not tail:
|
||||
return cls._truncate_text_content(content, max_chars)
|
||||
return f"{head}{marker}{tail}"
|
||||
def _head_only() -> str:
|
||||
marker = "\n... [profile truncated]"
|
||||
marker_units = cls._token_units(marker)
|
||||
if marker_units >= max_units:
|
||||
return cls._take_token_prefix(content, max_units)
|
||||
head = cls._take_token_prefix(content, max_units - marker_units).rstrip()
|
||||
return f"{head}{marker}" if head else cls._take_token_prefix(content, max_units)
|
||||
|
||||
def _read_session_start_text(
|
||||
lines = content.split("\n")
|
||||
head_line_count = 8
|
||||
if len(lines) <= head_line_count + 4:
|
||||
return _head_only()
|
||||
|
||||
marker = "\n... [profile middle elided] ...\n"
|
||||
remaining = max_units - cls._token_units(marker)
|
||||
if remaining <= 0:
|
||||
return _head_only()
|
||||
|
||||
head = cls._take_token_prefix(
|
||||
"\n".join(lines[:head_line_count]),
|
||||
remaining // 2,
|
||||
).rstrip()
|
||||
tail = cls._take_token_suffix(
|
||||
"\n".join(lines[head_line_count:]),
|
||||
remaining - cls._token_units(head),
|
||||
).lstrip()
|
||||
return f"{head}{marker}{tail}" if tail else _head_only()
|
||||
|
||||
def _read_session_start_profile(
|
||||
self,
|
||||
client: _VikingClient,
|
||||
endpoint: str,
|
||||
uri: str,
|
||||
*,
|
||||
skip_placeholder: bool = False,
|
||||
timeout: Optional[float] = None,
|
||||
deadline: float,
|
||||
request_timeout: float,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
kwargs = {"timeout": timeout} if timeout is not None else {}
|
||||
resp = client.get(endpoint, params={"uri": uri}, **kwargs)
|
||||
timeout = self._remaining_recall_timeout(deadline, request_timeout)
|
||||
resp = client.get(
|
||||
"/api/v1/content/read",
|
||||
params={"uri": _PROFILE_URI},
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
if _status_code_from_error(e) in {404, 410}:
|
||||
return ""
|
||||
return None
|
||||
content = self._extract_text_content(resp)
|
||||
if skip_placeholder and self._is_placeholder_overview(content):
|
||||
return ""
|
||||
return content
|
||||
return self._extract_text_content(resp)
|
||||
|
||||
def _list_session_start_memories(
|
||||
self,
|
||||
client: _VikingClient,
|
||||
uri: str,
|
||||
*,
|
||||
deadline: float,
|
||||
request_timeout: float,
|
||||
) -> List[Dict[str, str]]:
|
||||
try:
|
||||
timeout = self._remaining_recall_timeout(deadline, request_timeout)
|
||||
resp = client.get(
|
||||
"/api/v1/fs/ls",
|
||||
params={"uri": uri, **_SESSION_START_LIST_PARAMS},
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return self._extract_memory_listing(resp)
|
||||
|
||||
def _read_session_start_memory_parts(
|
||||
self,
|
||||
*,
|
||||
client: Optional[_VikingClient] = None,
|
||||
request_timeout: Optional[float] = None,
|
||||
) -> Dict[str, Optional[str]]:
|
||||
deadline: float,
|
||||
request_timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
active_client = client or self._client
|
||||
if not active_client:
|
||||
return {}
|
||||
|
||||
profile_uri, _level = _PROFILE_READS[0]
|
||||
profile = self._read_session_start_profile(
|
||||
active_client,
|
||||
deadline=deadline,
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
if profile is None:
|
||||
return {"profile": None, "preferences": [], "entities": []}
|
||||
return {
|
||||
"profile": self._read_session_start_text(
|
||||
"profile": profile,
|
||||
"preferences": self._list_session_start_memories(
|
||||
active_client,
|
||||
"/api/v1/content/read",
|
||||
profile_uri,
|
||||
timeout=request_timeout,
|
||||
_PREFERENCES_URI,
|
||||
deadline=deadline,
|
||||
request_timeout=request_timeout,
|
||||
),
|
||||
"preferences": self._read_session_start_text(
|
||||
"entities": self._list_session_start_memories(
|
||||
active_client,
|
||||
"/api/v1/content/overview",
|
||||
_PREFERENCES_OVERVIEW_URI,
|
||||
skip_placeholder=True,
|
||||
timeout=request_timeout,
|
||||
),
|
||||
"entities": self._read_session_start_text(
|
||||
active_client,
|
||||
"/api/v1/content/overview",
|
||||
_ENTITIES_OVERVIEW_URI,
|
||||
skip_placeholder=True,
|
||||
timeout=request_timeout,
|
||||
_ENTITIES_URI,
|
||||
deadline=deadline,
|
||||
request_timeout=request_timeout,
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_session_start_memory_block(
|
||||
*,
|
||||
profile: str = "",
|
||||
preferences: str = "",
|
||||
entities: str = "",
|
||||
def _assemble_session_start_memory_block(
|
||||
profile: str,
|
||||
preference_lines: List[str],
|
||||
entity_lines: List[str],
|
||||
) -> str:
|
||||
lines: List[str] = ["## Session Memory"]
|
||||
lines: List[str] = []
|
||||
if profile:
|
||||
lines.extend([
|
||||
' <user-profile uri="viking://user/memories/profile.md">',
|
||||
f'<user-profile uri="{_PROFILE_URI}">',
|
||||
profile,
|
||||
" </user-profile>",
|
||||
"</user-profile>",
|
||||
])
|
||||
if preferences or entities:
|
||||
lines.append(" <available-memories>")
|
||||
if preferences:
|
||||
lines.extend([
|
||||
f' <preferences uri="{_PREFERENCES_OVERVIEW_URI}">',
|
||||
preferences,
|
||||
" </preferences>",
|
||||
])
|
||||
if entities:
|
||||
lines.extend([
|
||||
f' <entities uri="{_ENTITIES_OVERVIEW_URI}">',
|
||||
entities,
|
||||
" </entities>",
|
||||
])
|
||||
lines.append(" </available-memories>")
|
||||
if len(lines) == 1:
|
||||
return ""
|
||||
if preference_lines or entity_lines:
|
||||
lines.append("<available-memories>")
|
||||
lines.extend(preference_lines)
|
||||
lines.extend(entity_lines)
|
||||
lines.append("</available-memories>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _budget_session_start_memory_parts(self, parts: Dict[str, str], max_chars: int) -> Dict[str, str]:
|
||||
profile = parts.get("profile", "").strip()
|
||||
preferences = parts.get("preferences", "").strip()
|
||||
entities = parts.get("entities", "").strip()
|
||||
full = self._format_session_start_memory_block(
|
||||
profile=profile,
|
||||
preferences=preferences,
|
||||
entities=entities,
|
||||
)
|
||||
if not full or self._weighted_context_len(full) <= max_chars:
|
||||
return {"profile": profile, "preferences": preferences, "entities": entities}
|
||||
@classmethod
|
||||
def _format_memory_listing(
|
||||
cls,
|
||||
uri: str,
|
||||
entries: List[Dict[str, str]],
|
||||
max_units: int,
|
||||
) -> tuple[List[str], int]:
|
||||
if not entries or max_units <= 0:
|
||||
return [], 0
|
||||
|
||||
header = f" {uri}/"
|
||||
header_units = cls._token_units(header)
|
||||
if header_units > max_units:
|
||||
stub = f" {uri}/ ({len(entries)} entries; use `viking_search`)"
|
||||
stub_units = cls._token_units(stub)
|
||||
return ([stub], stub_units) if stub_units <= max_units else ([], 0)
|
||||
|
||||
lines = [header]
|
||||
used = header_units
|
||||
newline_units = cls._token_units("\n")
|
||||
for index, entry in enumerate(entries):
|
||||
abstract = entry.get("abstract", "")
|
||||
description = f" — {abstract}" if abstract else ""
|
||||
line = f" - {entry['name']}{description}"
|
||||
line_units = newline_units + cls._token_units(line)
|
||||
if used + line_units > max_units:
|
||||
remaining = len(entries) - index
|
||||
tail = f" ... +{remaining} more, use `viking_search`"
|
||||
tail_units = newline_units + cls._token_units(tail)
|
||||
if used + tail_units <= max_units:
|
||||
lines.append(tail)
|
||||
used += tail_units
|
||||
break
|
||||
lines.append(line)
|
||||
used += line_units
|
||||
return lines, used
|
||||
|
||||
@classmethod
|
||||
def _build_session_start_memory_block(
|
||||
cls,
|
||||
*,
|
||||
profile: str,
|
||||
preferences: List[Dict[str, str]],
|
||||
entities: List[Dict[str, str]],
|
||||
token_budget: int,
|
||||
) -> str:
|
||||
profile = profile.strip()
|
||||
if not profile and not preferences and not entities:
|
||||
return ""
|
||||
|
||||
placeholder = "\0"
|
||||
placeholder_block = self._format_session_start_memory_block(
|
||||
profile=placeholder if profile else "",
|
||||
preferences=placeholder if preferences else "",
|
||||
entities=placeholder if entities else "",
|
||||
scaffold = cls._assemble_session_start_memory_block(
|
||||
placeholder if profile else "",
|
||||
[placeholder] if preferences else [],
|
||||
[placeholder] if entities else [],
|
||||
)
|
||||
present_count = sum(1 for value in (profile, preferences, entities) if value)
|
||||
overhead = self._weighted_context_len(placeholder_block) - present_count
|
||||
content_budget = max_chars - overhead
|
||||
if content_budget <= 0:
|
||||
return {"profile": "", "preferences": "", "entities": ""}
|
||||
placeholder_count = int(bool(profile)) + int(bool(preferences)) + int(bool(entities))
|
||||
overhead_units = cls._token_units(scaffold) - placeholder_count
|
||||
available_units = max(0, (token_budget * 4) - overhead_units)
|
||||
|
||||
has_listings = bool(preferences or entities)
|
||||
result = {"profile": "", "preferences": "", "entities": ""}
|
||||
remaining = content_budget
|
||||
if profile:
|
||||
profile_budget = remaining if not has_listings else min(
|
||||
self._weighted_context_len(profile),
|
||||
max(0, int(content_budget * 0.6)),
|
||||
)
|
||||
result["profile"] = self._truncate_profile_content(profile, profile_budget)
|
||||
remaining -= self._weighted_context_len(result["profile"])
|
||||
profile_text = ""
|
||||
if profile and available_units > 0:
|
||||
profile_units = min(available_units, token_budget * 2)
|
||||
profile_text = cls._truncate_profile_content(profile, profile_units)
|
||||
available_units -= cls._token_units(profile_text)
|
||||
|
||||
listing_values = {"preferences": preferences, "entities": entities}
|
||||
listing_keys = [key for key, value in listing_values.items() if value]
|
||||
for idx, key in enumerate(listing_keys):
|
||||
if remaining <= 0:
|
||||
break
|
||||
value = listing_values[key]
|
||||
budget = remaining if idx == len(listing_keys) - 1 else max(0, remaining // 2)
|
||||
result[key] = self._truncate_text_content(value, budget)
|
||||
remaining -= self._weighted_context_len(result[key])
|
||||
return result
|
||||
preference_lines: List[str] = []
|
||||
entity_lines: List[str] = []
|
||||
if preferences and entities:
|
||||
preference_budget = available_units // 2
|
||||
else:
|
||||
preference_budget = available_units
|
||||
preference_lines, preference_units = cls._format_memory_listing(
|
||||
_PREFERENCES_URI,
|
||||
preferences,
|
||||
preference_budget,
|
||||
)
|
||||
available_units -= preference_units
|
||||
entity_lines, _ = cls._format_memory_listing(
|
||||
_ENTITIES_URI,
|
||||
entities,
|
||||
available_units,
|
||||
)
|
||||
|
||||
return cls._assemble_session_start_memory_block(
|
||||
profile_text,
|
||||
preference_lines,
|
||||
entity_lines,
|
||||
)
|
||||
|
||||
def _session_start_memory_context(self, session_id: str) -> str:
|
||||
session_key = session_id or self._session_id or "__openviking_default_session__"
|
||||
|
|
@ -3203,27 +3265,24 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
return ""
|
||||
try:
|
||||
cfg = self._recall_config()
|
||||
deadline = time.monotonic() + cfg["timeout_seconds"]
|
||||
raw_parts = self._read_session_start_memory_parts(
|
||||
deadline=deadline,
|
||||
request_timeout=cfg["request_timeout_seconds"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("OpenViking session-start memory prefetch failed: %s", e)
|
||||
return ""
|
||||
profile_failed = raw_parts.get("profile") is None
|
||||
parts = {key: value or "" for key, value in raw_parts.items()}
|
||||
if not any(value.strip() for value in parts.values()):
|
||||
if not profile_failed:
|
||||
self._profile_prefetched_sessions.add(session_key)
|
||||
profile = raw_parts.get("profile")
|
||||
if profile is None:
|
||||
return ""
|
||||
budgeted = self._budget_session_start_memory_parts(parts, self._profile_max_chars())
|
||||
block = self._format_session_start_memory_block(**budgeted)
|
||||
if not block:
|
||||
if not profile_failed:
|
||||
self._profile_prefetched_sessions.add(session_key)
|
||||
return ""
|
||||
if not profile_failed:
|
||||
self._profile_prefetched_sessions.add(session_key)
|
||||
return block
|
||||
self._profile_prefetched_sessions.add(session_key)
|
||||
return self._build_session_start_memory_block(
|
||||
profile=profile,
|
||||
preferences=raw_parts.get("preferences") or [],
|
||||
entities=raw_parts.get("entities") or [],
|
||||
token_budget=self._profile_token_budget(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp_score(value: Any) -> float:
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ def make_prefetch_provider(monkeypatch, responses, **env):
|
|||
"OPENVIKING_RECALL_FULL_READ_LIMIT",
|
||||
"OPENVIKING_RECALL_PREFER_ABSTRACT",
|
||||
"OPENVIKING_RECALL_RESOURCES",
|
||||
"OPENVIKING_PROFILE_MAX_CHARS",
|
||||
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in env.items():
|
||||
|
|
@ -966,7 +966,7 @@ class TestOpenVikingRead:
|
|||
|
||||
class TestOpenVikingAutoRecallPrefetch:
|
||||
def test_prefetch_e2e_sends_limit_and_reads_l2_content(self, monkeypatch):
|
||||
records = {"searches": [], "reads": [], "headers": []}
|
||||
records = {"searches": [], "reads": [], "listings": [], "headers": []}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, payload):
|
||||
|
|
@ -988,12 +988,41 @@ class TestOpenVikingAutoRecallPrefetch:
|
|||
if parsed.path == "/api/v1/content/read":
|
||||
query = parse_qs(parsed.query)
|
||||
uri = query.get("uri", [""])[0]
|
||||
if uri.startswith("viking://user/memories/"):
|
||||
self.send_error(404)
|
||||
if uri == "viking://user/memories/profile.md":
|
||||
self._send_json({"result": "E2E user profile."})
|
||||
return
|
||||
records["reads"].append(uri)
|
||||
self._send_json({"result": {"content": "E2E full L2 memory content."}})
|
||||
return
|
||||
if parsed.path == "/api/v1/fs/ls":
|
||||
query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
|
||||
records["listings"].append(query)
|
||||
uri = query.get("uri")
|
||||
if uri == "viking://user/memories/preferences":
|
||||
self._send_json({
|
||||
"result": [
|
||||
{"isDir": True, "rel_path": "owner", "abstract": "ignored"},
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "owner/answers.md",
|
||||
"abstract": "Prefers source-backed answers.",
|
||||
},
|
||||
]
|
||||
})
|
||||
return
|
||||
if uri == "viking://user/memories/entities":
|
||||
self._send_json({
|
||||
"result": [
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "people/ada.md",
|
||||
"abstract": "Ada is the project owner.",
|
||||
}
|
||||
]
|
||||
})
|
||||
return
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
|
|
@ -1033,7 +1062,7 @@ class TestOpenVikingAutoRecallPrefetch:
|
|||
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
|
||||
"OPENVIKING_RECALL_PREFER_ABSTRACT",
|
||||
"OPENVIKING_RECALL_RESOURCES",
|
||||
"OPENVIKING_PROFILE_MAX_CHARS",
|
||||
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
||||
"OPENVIKING_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
|
@ -1053,9 +1082,20 @@ class TestOpenVikingAutoRecallPrefetch:
|
|||
thread.join(timeout=3.0)
|
||||
|
||||
assert block.startswith("## OpenViking Context\n")
|
||||
assert "E2E user profile." in block
|
||||
assert "owner/answers.md — Prefers source-backed answers." in block
|
||||
assert "people/ada.md — Ada is the project owner." in block
|
||||
assert "E2E full L2 memory content." in block
|
||||
assert "E2E abstract should not be injected." not in block
|
||||
assert records["reads"] == ["viking://user/peers/hermes/memories/e2e-full.md"]
|
||||
assert [listing["uri"] for listing in records["listings"]] == [
|
||||
"viking://user/memories/preferences",
|
||||
"viking://user/memories/entities",
|
||||
]
|
||||
assert all(listing["output"] == "agent" for listing in records["listings"])
|
||||
assert all(listing["recursive"].lower() == "true" for listing in records["listings"])
|
||||
assert all(listing["abs_limit"] == "512" for listing in records["listings"])
|
||||
assert all(listing["node_limit"] == "512" for listing in records["listings"])
|
||||
assert len(records["searches"]) == 1
|
||||
assert records["searches"][0]["context_type"] == "memory"
|
||||
assert records["searches"][0]["session_id"] == "e2e-session"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def _clear_openviking_env(monkeypatch):
|
|||
"OPENVIKING_USER",
|
||||
"OPENVIKING_AGENT",
|
||||
"OPENVIKING_CLI_CONFIG_FILE",
|
||||
"OPENVIKING_PROFILE_MAX_CHARS",
|
||||
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
|
@ -3580,12 +3580,28 @@ def _make_prefetch_provider() -> OpenVikingMemoryProvider:
|
|||
return provider
|
||||
|
||||
|
||||
def _mock_session_start_reads(provider: OpenVikingMemoryProvider, responses: dict[tuple[str, str], object]):
|
||||
_SESSION_START_LIST_PARAMS = {
|
||||
"output": "agent",
|
||||
"recursive": True,
|
||||
"abs_limit": 512,
|
||||
"node_limit": 512,
|
||||
}
|
||||
|
||||
|
||||
def _memory_listing(*entries):
|
||||
return list(entries)
|
||||
|
||||
|
||||
def _mock_session_start_reads(
|
||||
provider: OpenVikingMemoryProvider,
|
||||
responses: dict[tuple[str, str], object],
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fake_get(path, params=None, **kwargs):
|
||||
uri = (params or {}).get("uri", "")
|
||||
calls.append((path, uri))
|
||||
request_params = dict(params or {})
|
||||
uri = request_params.get("uri", "")
|
||||
calls.append((path, request_params, kwargs.get("timeout")))
|
||||
response = responses.get((path, uri), "")
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
|
|
@ -3595,6 +3611,15 @@ def _mock_session_start_reads(provider: OpenVikingMemoryProvider, responses: dic
|
|||
return calls
|
||||
|
||||
|
||||
def test_session_start_token_estimator_matches_shared_openviking_contract():
|
||||
provider = OpenVikingMemoryProvider
|
||||
|
||||
assert provider._estimate_tokens("abcd") == 1
|
||||
assert provider._estimate_tokens("设") == 2
|
||||
assert provider._estimate_tokens("设置") == 3
|
||||
assert provider._estimate_tokens("设置ab") == 4
|
||||
|
||||
|
||||
def test_prefetch_prepends_session_start_memory_context_once_per_session():
|
||||
provider = _make_prefetch_provider()
|
||||
calls = _mock_session_start_reads(
|
||||
|
|
@ -3603,11 +3628,26 @@ def test_prefetch_prepends_session_start_memory_context_once_per_session():
|
|||
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
||||
"User prefers concise answers."
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): (
|
||||
"# Preferences\n- Keep replies compact."
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"): _memory_listing(
|
||||
{"isDir": True, "rel_path": "owner"},
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "owner/z-last.md",
|
||||
"abstract": " Keep replies compact. ",
|
||||
},
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "owner/a-first.md",
|
||||
"abstract": "Verify source before editing.",
|
||||
},
|
||||
{"isDir": False, "rel_path": "owner/ignored.txt", "abstract": "ignore"},
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): (
|
||||
"# Entities\n- Ada Lovelace: collaborator."
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"): _memory_listing(
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "people/ada.md",
|
||||
"abstract": "Ada Lovelace is a collaborator.",
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
|
@ -3616,20 +3656,30 @@ def test_prefetch_prepends_session_start_memory_context_once_per_session():
|
|||
first = provider.prefetch("What should we recall?", session_id="sid-123")
|
||||
second = provider.prefetch("What should we recall?", session_id="sid-123")
|
||||
|
||||
assert "## Session Memory" in first
|
||||
assert '<user-profile uri="viking://user/memories/profile.md">' in first
|
||||
assert "User prefers concise answers." in first
|
||||
assert '<preferences uri="viking://user/memories/preferences/">' in first
|
||||
assert "Keep replies compact." in first
|
||||
assert '<entities uri="viking://user/memories/entities/">' in first
|
||||
assert "Ada Lovelace: collaborator." in first
|
||||
assert "<available-memories>" in first
|
||||
assert "viking://user/memories/preferences/" in first
|
||||
assert "owner/z-last.md — Keep replies compact." in first
|
||||
assert first.index("owner/a-first.md") < first.index("owner/z-last.md")
|
||||
assert "viking://user/memories/entities/" in first
|
||||
assert "people/ada.md — Ada Lovelace is a collaborator." in first
|
||||
assert "owner/ignored.txt" not in first
|
||||
assert "<preferences" not in first
|
||||
assert "<entities" not in first
|
||||
assert "recalled context" in first
|
||||
assert "## Session Memory" not in second
|
||||
assert "<user-profile" not in second
|
||||
assert "recalled context" in second
|
||||
assert calls == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"),
|
||||
assert [(path, params) for path, params, _timeout in calls] == [
|
||||
("/api/v1/content/read", {"uri": "viking://user/memories/profile.md"}),
|
||||
(
|
||||
"/api/v1/fs/ls",
|
||||
{"uri": "viking://user/memories/preferences", **_SESSION_START_LIST_PARAMS},
|
||||
),
|
||||
(
|
||||
"/api/v1/fs/ls",
|
||||
{"uri": "viking://user/memories/entities", **_SESSION_START_LIST_PARAMS},
|
||||
),
|
||||
]
|
||||
assert provider._search_prefetch_context.call_count == 2
|
||||
|
||||
|
|
@ -3642,8 +3692,8 @@ def test_prefetch_can_return_session_start_memory_for_short_query():
|
|||
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
||||
"User profile is Ada."
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): "",
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"): [],
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3651,21 +3701,24 @@ def test_prefetch_can_return_session_start_memory_for_short_query():
|
|||
context = provider.prefetch("hi", session_id="sid-123")
|
||||
|
||||
assert "## OpenViking Context" in context
|
||||
assert "## Session Memory" in context
|
||||
assert "User profile is Ada." in context
|
||||
provider._search_prefetch_context.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_session_start_memory_reads_use_bounded_timeout(monkeypatch):
|
||||
monkeypatch.setenv("OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS", "0.25")
|
||||
def test_prefetch_session_start_reads_share_one_total_deadline(monkeypatch):
|
||||
monkeypatch.setenv("OPENVIKING_RECALL_TIMEOUT_SECONDS", "2")
|
||||
monkeypatch.setenv("OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS", "2")
|
||||
provider = _make_prefetch_provider()
|
||||
calls = []
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(openviking_module.time, "monotonic", lambda: clock[0])
|
||||
|
||||
def fake_get(path, params=None, **kwargs):
|
||||
calls.append((path, (params or {}).get("uri", ""), kwargs.get("timeout")))
|
||||
clock[0] += 0.75
|
||||
if (params or {}).get("uri") == "viking://user/memories/profile.md":
|
||||
return {"result": "User profile is Ada."}
|
||||
return {"result": ""}
|
||||
return {"result": []}
|
||||
|
||||
provider._client.get.side_effect = fake_get
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3673,11 +3726,12 @@ def test_prefetch_session_start_memory_reads_use_bounded_timeout(monkeypatch):
|
|||
context = provider.prefetch("hi", session_id="sid-123")
|
||||
|
||||
assert "User profile is Ada." in context
|
||||
assert calls == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md", 0.25),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/", 0.25),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/", 0.25),
|
||||
assert [(path, uri) for path, uri, _timeout in calls] == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
||||
]
|
||||
assert [timeout for _path, _uri, timeout in calls] == pytest.approx([2.0, 1.25, 0.5])
|
||||
|
||||
|
||||
def test_prefetch_retries_session_start_memory_after_empty_failed_attempt():
|
||||
|
|
@ -3698,6 +3752,7 @@ def test_prefetch_retries_session_start_memory_after_empty_failed_attempt():
|
|||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
||||
first = provider.prefetch("hi", session_id="sid-123")
|
||||
assert provider._client.get.call_count == 1
|
||||
provider._turn_count = 1
|
||||
second = provider.prefetch("hi", session_id="sid-123")
|
||||
|
||||
|
|
@ -3712,8 +3767,8 @@ def test_prefetch_marks_successful_empty_session_start_memory_as_checked():
|
|||
provider,
|
||||
{
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): "",
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): "",
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"): [],
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3723,10 +3778,10 @@ def test_prefetch_marks_successful_empty_session_start_memory_as_checked():
|
|||
|
||||
assert first == ""
|
||||
assert second == ""
|
||||
assert calls == [
|
||||
assert [(path, params["uri"]) for path, params, _timeout in calls] == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
||||
]
|
||||
provider._search_prefetch_context.assert_not_called()
|
||||
|
||||
|
|
@ -3740,9 +3795,9 @@ def test_prefetch_marks_checked_when_secondary_session_memory_read_fails():
|
|||
calls.append((path, uri))
|
||||
if uri == "viking://user/memories/profile.md":
|
||||
return {"result": "User profile is Ada."}
|
||||
if uri == "viking://user/memories/entities/":
|
||||
raise RuntimeError("transient entities overview failure")
|
||||
return {"result": ""}
|
||||
if uri == "viking://user/memories/entities":
|
||||
raise RuntimeError("transient entities listing failure")
|
||||
return {"result": []}
|
||||
|
||||
provider._client.get.side_effect = fake_get
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3755,8 +3810,8 @@ def test_prefetch_marks_checked_when_secondary_session_memory_read_fails():
|
|||
assert second == ""
|
||||
assert calls == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
||||
]
|
||||
provider._search_prefetch_context.assert_not_called()
|
||||
|
||||
|
|
@ -3770,7 +3825,7 @@ def test_prefetch_reinjects_after_in_place_compression_same_session():
|
|||
uri = (params or {}).get("uri", "")
|
||||
if uri == "viking://user/memories/profile.md":
|
||||
return {"result": next(profiles)}
|
||||
return {"result": ""}
|
||||
return {"result": []}
|
||||
|
||||
provider._client.get.side_effect = fake_get
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3792,7 +3847,7 @@ def test_prefetch_reinjects_for_new_session_id():
|
|||
uri = (params or {}).get("uri", "")
|
||||
if uri == "viking://user/memories/profile.md":
|
||||
return {"result": next(profiles)}
|
||||
return {"result": ""}
|
||||
return {"result": []}
|
||||
|
||||
provider._client.get.side_effect = fake_get
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3804,16 +3859,22 @@ def test_prefetch_reinjects_for_new_session_id():
|
|||
assert "Session B profile." in second
|
||||
|
||||
|
||||
def test_prefetch_degrades_cleanly_when_some_session_memory_parts_are_missing():
|
||||
def test_prefetch_degrades_cleanly_when_profile_is_definitively_missing():
|
||||
provider = _make_prefetch_provider()
|
||||
_mock_session_start_reads(
|
||||
provider,
|
||||
{
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError("missing"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): (
|
||||
"# Preferences\n- Likes source-backed answers."
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
||||
openviking_module._OpenVikingHTTPError("not found", 404)
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): "",
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"): _memory_listing(
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": "owner/review.md",
|
||||
"abstract": "Likes source-backed answers.",
|
||||
},
|
||||
),
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3822,54 +3883,31 @@ def test_prefetch_degrades_cleanly_when_some_session_memory_parts_are_missing():
|
|||
|
||||
assert "Likes source-backed answers." in context
|
||||
assert "<user-profile" not in context
|
||||
assert "<entities" not in context
|
||||
assert '<preferences uri="viking://user/memories/preferences/">' in context
|
||||
|
||||
|
||||
def test_prefetch_omits_placeholder_directory_overviews_from_session_memory():
|
||||
provider = _make_prefetch_provider()
|
||||
_mock_session_start_reads(
|
||||
provider,
|
||||
{
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
||||
"User profile is Ada."
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): (
|
||||
"# viking://user/memories/preferences/\n\n[Directory abstract is not ready]"
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): (
|
||||
"# viking://user/memories/entities/\n\n[Directory overview is not generated]"
|
||||
),
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
||||
context = provider.prefetch("hi", session_id="sid-123")
|
||||
|
||||
assert "User profile is Ada." in context
|
||||
assert "<available-memories>" not in context
|
||||
assert "Directory abstract is not ready" not in context
|
||||
assert "Directory overview is not generated" not in context
|
||||
assert "viking://user/memories/preferences/" in context
|
||||
|
||||
|
||||
def test_session_start_memory_context_respects_total_budget_and_preserves_profile_tail(monkeypatch):
|
||||
monkeypatch.setenv("OPENVIKING_PROFILE_MAX_CHARS", "700")
|
||||
monkeypatch.setenv("OPENVIKING_PROFILE_TOKEN_BUDGET", "6000")
|
||||
provider = _make_prefetch_provider()
|
||||
long_profile = "\n".join(
|
||||
["Profile head: user is Ada."]
|
||||
+ [f"profile middle {i}: {'x' * 30}" for i in range(40)]
|
||||
+ [f"profile middle {i}: {'设' * 30}" for i in range(300)]
|
||||
+ ["Profile tail: recent work is OpenViking."]
|
||||
)
|
||||
listing = _memory_listing(*[
|
||||
{
|
||||
"isDir": False,
|
||||
"rel_path": f"owner/topic-{index:02d}.md",
|
||||
"abstract": "偏好经过源码验证的答案" * 6,
|
||||
}
|
||||
for index in range(700)
|
||||
])
|
||||
_mock_session_start_reads(
|
||||
provider,
|
||||
{
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): long_profile,
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): (
|
||||
"Preferences overview " + ("p" * 500)
|
||||
),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): (
|
||||
"Entities overview " + ("e" * 500)
|
||||
),
|
||||
("/api/v1/fs/ls", "viking://user/memories/preferences"): listing,
|
||||
("/api/v1/fs/ls", "viking://user/memories/entities"): listing,
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
||||
|
|
@ -3877,10 +3915,11 @@ def test_session_start_memory_context_respects_total_budget_and_preserves_profil
|
|||
context = provider.prefetch("hi", session_id="sid-budget")
|
||||
block = context.removeprefix("## OpenViking Context\n")
|
||||
|
||||
assert len(block) <= 700
|
||||
assert provider._estimate_tokens(block) <= 6000
|
||||
assert "Profile head: user is Ada." in block
|
||||
assert "Profile tail: recent work is OpenViking." in block
|
||||
assert "[...] truncated" in block
|
||||
assert "[profile middle elided]" in block
|
||||
assert "<available-memories>" in block
|
||||
assert "viking_profile" not in block
|
||||
|
||||
|
||||
|
|
@ -3889,21 +3928,19 @@ def test_prefetch_does_not_auto_inject_memory_overview_when_profile_missing():
|
|||
calls = _mock_session_start_reads(
|
||||
provider,
|
||||
{
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError("missing"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"): "",
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError(
|
||||
"transient profile failure"
|
||||
),
|
||||
},
|
||||
)
|
||||
provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
|
||||
|
||||
context = provider.prefetch("What should we recall?", session_id="sid-123")
|
||||
|
||||
assert "## Session Memory" not in context
|
||||
assert "<user-profile" not in context
|
||||
assert "recalled context" in context
|
||||
assert calls == [
|
||||
assert [(path, params["uri"]) for path, params, _timeout in calls] == [
|
||||
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
||||
("/api/v1/content/overview", "viking://user/memories/preferences/"),
|
||||
("/api/v1/content/overview", "viking://user/memories/entities/"),
|
||||
]
|
||||
provider._search_prefetch_context.assert_called_once_with(
|
||||
"What should we recall?",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue