From 8af21330097d8c8de4066d99509fef8b8ed79ae3 Mon Sep 17 00:00:00 2001 From: Hao Zhe Date: Fri, 17 Jul 2026 22:25:08 +0800 Subject: [PATCH] fix(openviking): align session context with shared profile contract --- plugins/memory/openviking/__init__.py | 413 ++++++++++-------- tests/openviking_plugin/test_openviking.py | 50 ++- .../memory/test_openviking_provider.py | 217 +++++---- 3 files changed, 408 insertions(+), 272 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 513e0d1ec23..30ead17a74e 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -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([ - ' ', + f'', profile, - " ", + "", ]) - if preferences or entities: - lines.append(" ") - if preferences: - lines.extend([ - f' ', - preferences, - " ", - ]) - if entities: - lines.extend([ - f' ', - entities, - " ", - ]) - lines.append(" ") - if len(lines) == 1: - return "" + if preference_lines or entity_lines: + lines.append("") + lines.extend(preference_lines) + lines.extend(entity_lines) + lines.append("") 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: diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index f5a4f84ed7f..a2e7de62ea8 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -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" diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 69a5e8619aa..df2e5632be8 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -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 '' in first assert "User prefers concise answers." in first - assert '' in first - assert "Keep replies compact." in first - assert '' in first - assert "Ada Lovelace: collaborator." in first + assert "" 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 "' 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 "" 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 "" 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 "