fix(openviking): align session context with shared profile contract

This commit is contained in:
Hao Zhe 2026-07-17 22:25:08 +08:00 committed by kshitij
parent 11c1ca01c5
commit 8af2133009
3 changed files with 408 additions and 272 deletions

View file

@ -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:

View file

@ -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"

View file

@ -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?",