mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Salvage of PR #48927 by @ehz0ah, which consolidates OpenViking recall work from #41706 (@huangxun375-stack), #33260, #49975, and #32444. Replaces stale background post-turn prefetch warming with synchronous current-query recall. The old queue_prefetch warmed the PREVIOUS user message while turn-start recall consumed the CURRENT one, so injected context was always about the wrong topic. Changes: - prefetch() now does session-aware /api/v1/search/search with the current query, falls back to /api/v1/search/find on failure - Contract-safe payloads: limit, score_threshold, context_type, session_id — no top_k, no search-body mode, no target_uri - L2 content reads for items with level=2 or empty abstracts, capped at full_read_limit (default 2) - Local ranking (score + query-token overlap + leaf boost), dedup, score threshold, and injected-char budget - queue_prefetch() is now a no-op (background warming removed) - Additive batched viking_read: uris param accepts up to 3 URIs - Per-request timeout support on _VikingClient.get/post/delete - Removes stale _prefetch_result/_prefetch_thread/_prefetch_generation state and _invalidate_prefetch_state() - Strengthened system_prompt_block guidance Salvage follow-up fixes: - Expose all 8 recall config knobs in get_config_schema() (PR #48927 had removed them; #41706 correctly exposed them). Env vars remain as internal mechanism but are now visible in setup wizard. - Lower default timeout 8s→4s, request_timeout 6s→3s, full_read_limit 3→2 to reduce per-turn blocking latency. Co-authored-by: Hao Zhe <haozhe4547@gmail.com> Co-authored-by: Eurekaxun <eurekaxun@163.com>
1403 lines
55 KiB
Python
1403 lines
55 KiB
Python
"""Tests for plugins/memory/openviking/__init__.py — URI normalization and payload handling."""
|
|
|
|
import json
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from typing import Any, cast
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import plugins.memory.openviking as openviking_plugin
|
|
from plugins.memory.openviking import OpenVikingMemoryProvider
|
|
|
|
|
|
def _write_skill(skills_dir, name, body="Do the thing."):
|
|
skill_dir = skills_dir / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n"
|
|
)
|
|
return skill_dir
|
|
|
|
|
|
def _write_bundle(bundles_dir, slug, skills):
|
|
bundles_dir.mkdir(parents=True, exist_ok=True)
|
|
lines = [f"name: {slug}", "skills:"]
|
|
lines.extend(f" - {skill}" for skill in skills)
|
|
(bundles_dir / f"{slug}.yaml").write_text("\n".join(lines) + "\n")
|
|
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, responses):
|
|
self.responses = responses
|
|
self.calls = []
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
self.calls.append((path, params or {}))
|
|
response = self.responses[(path, tuple(sorted((params or {}).items())))]
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
self.calls.append((path, payload or {}))
|
|
response = self.responses.get((path, tuple(sorted((payload or {}).items()))), {})
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
|
|
class RecordingVikingClient:
|
|
calls = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
self.calls.append((path, payload or {}))
|
|
return {"result": {"memories": [], "resources": []}}
|
|
|
|
|
|
def _recall_context_key(value):
|
|
if isinstance(value, list):
|
|
return tuple(value)
|
|
return value
|
|
|
|
|
|
class FakeRecallClient:
|
|
calls = []
|
|
responses = {}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
payload = payload or {}
|
|
self.__class__.calls.append(("post", path, dict(payload)))
|
|
context_type = _recall_context_key(payload.get("context_type"))
|
|
key = (path, context_type, payload.get("query"), payload.get("session_id"))
|
|
if key not in self.__class__.responses:
|
|
key = (path, context_type, payload.get("query"))
|
|
if key not in self.__class__.responses:
|
|
key = (path, context_type)
|
|
response = self.__class__.responses[key]
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
params = params or {}
|
|
self.__class__.calls.append(("get", path, dict(params)))
|
|
response = self.__class__.responses[(path, params.get("uri"))]
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
|
|
def make_prefetch_provider(monkeypatch, responses, **env):
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", FakeRecallClient)
|
|
FakeRecallClient.calls = []
|
|
FakeRecallClient.responses = responses
|
|
for key in (
|
|
"OPENVIKING_RECALL_LIMIT",
|
|
"OPENVIKING_RECALL_SCORE_THRESHOLD",
|
|
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
|
|
"OPENVIKING_RECALL_TIMEOUT_SECONDS",
|
|
"OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS",
|
|
"OPENVIKING_RECALL_FULL_READ_LIMIT",
|
|
"OPENVIKING_RECALL_PREFER_ABSTRACT",
|
|
"OPENVIKING_RECALL_RESOURCES",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
for key, value in env.items():
|
|
monkeypatch.setenv(key, str(value))
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = object()
|
|
provider._endpoint = "http://openviking.test"
|
|
provider._account = "default"
|
|
provider._user = "default"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "session-test"
|
|
return provider
|
|
|
|
|
|
def wait_prefetch(provider, query="What should we recall?", session_id="session-test"):
|
|
return provider.prefetch(query, session_id=session_id)
|
|
|
|
|
|
class TestOpenVikingSummaryUriNormalization:
|
|
def test_normalize_summary_uri_maps_pseudo_files_to_parent_directory(self):
|
|
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://user/hermes/.overview.md") == "viking://user/hermes"
|
|
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://resources/.abstract.md") == "viking://resources"
|
|
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://") == "viking://"
|
|
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://user/hermes/memories/profile.md") == "viking://user/hermes/memories/profile.md"
|
|
|
|
class TestOpenVikingSkillQuerySafety:
|
|
def test_derive_returns_empty_string_for_non_string_input(self):
|
|
assert openviking_plugin._derive_openviking_user_text(None) == ""
|
|
assert openviking_plugin._derive_openviking_user_text(123) == ""
|
|
assert openviking_plugin._derive_openviking_user_text([{"text": "hi"}]) == ""
|
|
|
|
def test_derive_passes_through_non_skill_content(self):
|
|
assert (
|
|
openviking_plugin._derive_openviking_user_text("regular user message")
|
|
== "regular user message"
|
|
)
|
|
|
|
def test_derive_returns_empty_for_skill_scaffolding_with_no_instruction(self):
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "example" skill, indicating they want '
|
|
"you to follow its instructions. The full skill content is loaded below.]\n\n"
|
|
"# Example\n\n"
|
|
"Skill body only, no instruction."
|
|
)
|
|
|
|
assert openviking_plugin._derive_openviking_user_text(skill_message) == ""
|
|
|
|
def test_skill_markers_match_hermes_scaffolding(self, tmp_path, monkeypatch):
|
|
import agent.skill_bundles as skill_bundles
|
|
import agent.skill_commands as skill_commands
|
|
import tools.skills_tool as skills_tool
|
|
|
|
skills_dir = tmp_path / "skills"
|
|
bundles_dir = tmp_path / "skill-bundles"
|
|
_write_skill(skills_dir, "example")
|
|
_write_bundle(bundles_dir, "demo", ["example"])
|
|
|
|
monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir)
|
|
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
|
monkeypatch.setattr(skill_commands, "_skill_commands", {})
|
|
monkeypatch.setattr(skill_commands, "_skill_commands_platform", None)
|
|
monkeypatch.setattr(skill_bundles, "_bundles_cache", {})
|
|
monkeypatch.setattr(skill_bundles, "_bundles_cache_mtime", None)
|
|
|
|
skill_commands.scan_skill_commands()
|
|
single = skill_commands.build_skill_invocation_message(
|
|
"/example",
|
|
user_instruction="hello",
|
|
runtime_note="runtime detail",
|
|
)
|
|
assert single is not None
|
|
assert skill_commands._SKILL_INVOCATION_PREFIX in single
|
|
assert skill_commands._SINGLE_SKILL_MARKER in single
|
|
assert skill_commands._SINGLE_SKILL_INSTRUCTION in single
|
|
assert skill_commands._RUNTIME_NOTE in single
|
|
|
|
skill_bundles.scan_bundles()
|
|
bundle_result = skill_bundles.build_bundle_invocation_message(
|
|
"/demo",
|
|
user_instruction="hello",
|
|
)
|
|
assert bundle_result is not None
|
|
bundle, _, _ = bundle_result
|
|
assert skill_commands._BUNDLE_MARKER in bundle
|
|
assert skill_commands._BUNDLE_USER_INSTRUCTION in bundle
|
|
assert skill_commands._BUNDLE_FIRST_SKILL_BLOCK in bundle
|
|
|
|
def test_prefetch_searches_only_slash_skill_user_instruction(self, monkeypatch):
|
|
RecordingVikingClient.calls = []
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = cast(Any, object())
|
|
provider._endpoint = "http://openviking.test"
|
|
provider._api_key = ""
|
|
provider._account = "default"
|
|
provider._user = "default"
|
|
provider._agent = "hermes"
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
|
|
"you to follow its instructions. The full skill content is loaded below.]\n\n"
|
|
"# Skill Creator\n\n"
|
|
"Large skill body that must not be searched or embedded.\n\n"
|
|
"The user has provided the following instruction alongside the skill invocation: "
|
|
"make a skill for release triage"
|
|
)
|
|
|
|
provider.prefetch(skill_message)
|
|
|
|
assert RecordingVikingClient.calls == [
|
|
(
|
|
"/api/v1/search/find",
|
|
{
|
|
"query": "make a skill for release triage",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
},
|
|
),
|
|
]
|
|
|
|
def test_prefetch_searches_only_skill_bundle_user_instruction(self, monkeypatch):
|
|
RecordingVikingClient.calls = []
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = cast(Any, object())
|
|
provider._endpoint = "http://openviking.test"
|
|
provider._api_key = ""
|
|
provider._account = "default"
|
|
provider._user = "default"
|
|
provider._agent = "hermes"
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "backend-dev" skill bundle, '
|
|
"loading 2 skills together. Treat every skill below as active guidance for this turn.]\n\n"
|
|
"Bundle: backend-dev\n"
|
|
"Skills loaded: test-driven-development, code-review\n\n"
|
|
"User instruction: fix the failing retrieval test\n\n"
|
|
'[Loaded as part of the "backend-dev" skill bundle.]\n\n'
|
|
"Large bundled skill body that must not be searched or embedded."
|
|
)
|
|
|
|
provider.prefetch(skill_message)
|
|
|
|
assert RecordingVikingClient.calls == [
|
|
(
|
|
"/api/v1/search/find",
|
|
{
|
|
"query": "fix the failing retrieval test",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
},
|
|
),
|
|
]
|
|
|
|
def test_prefetch_skips_slash_skill_without_user_instruction(self, monkeypatch):
|
|
RecordingVikingClient.calls = []
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = cast(Any, object())
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
|
|
"you to follow its instructions. The full skill content is loaded below.]\n\n"
|
|
"# Skill Creator\n\n"
|
|
"Large skill body that must not be searched or embedded."
|
|
)
|
|
|
|
assert provider.prefetch(skill_message) == ""
|
|
|
|
assert RecordingVikingClient.calls == []
|
|
|
|
def test_sync_turn_stores_only_slash_skill_user_instruction(self, monkeypatch):
|
|
RecordingVikingClient.calls = []
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = cast(Any, object())
|
|
provider._endpoint = "http://openviking.test"
|
|
provider._api_key = ""
|
|
provider._account = "default"
|
|
provider._user = "default"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "session-1"
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
|
|
"you to follow its instructions. The full skill content is loaded below.]\n\n"
|
|
"# Skill Creator\n\n"
|
|
"Large skill body that must not be stored as user content.\n\n"
|
|
"The user has provided the following instruction alongside the skill invocation: "
|
|
"make a skill for release triage"
|
|
)
|
|
|
|
provider.sync_turn(skill_message, "Done.")
|
|
assert provider._drain_writers("session-1", timeout=5.0)
|
|
|
|
assert RecordingVikingClient.calls == [
|
|
(
|
|
"/api/v1/sessions/session-1/messages/batch",
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"parts": [
|
|
{"type": "text", "text": "make a skill for release triage"},
|
|
],
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"parts": [{"type": "text", "text": "Done."}],
|
|
"peer_id": "hermes",
|
|
},
|
|
]
|
|
},
|
|
),
|
|
]
|
|
|
|
def test_sync_turn_skips_slash_skill_without_user_instruction(self, monkeypatch):
|
|
RecordingVikingClient.calls = []
|
|
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = cast(Any, object())
|
|
skill_message = (
|
|
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
|
|
"you to follow its instructions. The full skill content is loaded below.]\n\n"
|
|
"# Skill Creator\n\n"
|
|
"Large skill body that must not be stored as user content."
|
|
)
|
|
|
|
provider.sync_turn(skill_message, "Done.")
|
|
|
|
assert provider._turn_count == 0
|
|
assert provider._inflight_writers == {}
|
|
assert RecordingVikingClient.calls == []
|
|
|
|
|
|
class TestOpenVikingConfigSchema:
|
|
def test_recall_policy_options_are_exposed_in_setup_schema(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
|
|
schema = provider.get_config_schema()
|
|
env_vars = {entry.get("env_var") for entry in schema}
|
|
|
|
assert "OPENVIKING_RECALL_LIMIT" in env_vars
|
|
assert "OPENVIKING_RECALL_SCORE_THRESHOLD" in env_vars
|
|
assert "OPENVIKING_RECALL_MAX_INJECTED_CHARS" in env_vars
|
|
assert "OPENVIKING_RECALL_TIMEOUT_SECONDS" in env_vars
|
|
assert "OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS" in env_vars
|
|
assert "OPENVIKING_RECALL_FULL_READ_LIMIT" in env_vars
|
|
assert "OPENVIKING_RECALL_PREFER_ABSTRACT" in env_vars
|
|
assert "OPENVIKING_RECALL_RESOURCES" in env_vars
|
|
assert provider._recall_config() == {
|
|
"limit": 6,
|
|
"score_threshold": 0.15,
|
|
"max_injected_chars": 4000,
|
|
"timeout_seconds": 4.0,
|
|
"request_timeout_seconds": 3.0,
|
|
"full_read_limit": 2,
|
|
"prefer_abstract": False,
|
|
"resources": False,
|
|
}
|
|
|
|
|
|
class TestOpenVikingTurnConversion:
|
|
def test_extract_current_turn_anchors_on_latest_matching_user_and_assistant(self):
|
|
messages = [
|
|
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
|
|
{"role": "assistant", "content": "Earlier answer."},
|
|
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "I will search the codebase.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_rg_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "shell_command",
|
|
"arguments": json.dumps({"command": "rg assemble"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_rg_1",
|
|
"name": "shell_command",
|
|
"content": "agent/context_engine.py: no preassemble hook",
|
|
},
|
|
{"role": "assistant", "content": "The current main does not expose assemble."},
|
|
]
|
|
|
|
turn = OpenVikingMemoryProvider._extract_current_turn_messages(
|
|
messages,
|
|
"Please inspect the repository for assemble hooks.",
|
|
"The current main does not expose assemble.",
|
|
)
|
|
|
|
assert turn == messages[2:]
|
|
|
|
def test_messages_to_openviking_batch_coalesces_tool_results(self):
|
|
turn = [
|
|
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "I will search the codebase.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_rg_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "shell_command",
|
|
"arguments": json.dumps({"command": "rg assemble"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_rg_1",
|
|
"name": "shell_command",
|
|
"content": "agent/context_engine.py: no preassemble hook",
|
|
},
|
|
{"role": "assistant", "content": "The current main does not expose assemble."},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert [message["role"] for message in batch] == ["user", "assistant", "assistant", "assistant"]
|
|
assert batch[0]["parts"] == [
|
|
{"type": "text", "text": "Please inspect the repository for assemble hooks."}
|
|
]
|
|
assert batch[1]["parts"] == [
|
|
{"type": "text", "text": "I will search the codebase."}
|
|
]
|
|
assert batch[2]["parts"] == [
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_rg_1",
|
|
"tool_name": "shell_command",
|
|
"tool_input": {"command": "rg assemble"},
|
|
"tool_output": "agent/context_engine.py: no preassemble hook",
|
|
"tool_status": "completed",
|
|
}
|
|
]
|
|
assert batch[3]["parts"] == [
|
|
{"type": "text", "text": "The current main does not expose assemble."}
|
|
]
|
|
|
|
def test_messages_to_openviking_batch_marks_json_tool_error_results(self):
|
|
turn = [
|
|
{"role": "user", "content": "Check the file."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_read_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_file",
|
|
"arguments": json.dumps({"path": "missing.md"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_read_1",
|
|
"name": "read_file",
|
|
"content": json.dumps({"error": "File not found", "exit_code": 1}),
|
|
},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert batch[1]["role"] == "assistant"
|
|
assert batch[1]["parts"] == [
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_read_1",
|
|
"tool_name": "read_file",
|
|
"tool_input": {"path": "missing.md"},
|
|
"tool_output": json.dumps({"error": "File not found", "exit_code": 1}),
|
|
"tool_status": "error",
|
|
}
|
|
]
|
|
|
|
def test_messages_to_openviking_batch_keeps_pending_tool_call_without_result(self):
|
|
turn = [
|
|
{"role": "user", "content": "Start a long running check."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Starting it now.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_long_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "long_check",
|
|
"arguments": json.dumps({"target": "repo"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert batch[1]["parts"] == [
|
|
{"type": "text", "text": "Starting it now."},
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_long_1",
|
|
"tool_name": "long_check",
|
|
"tool_input": {"target": "repo"},
|
|
"tool_status": "pending",
|
|
},
|
|
]
|
|
|
|
def test_messages_to_openviking_batch_coalesces_adjacent_tool_results(self):
|
|
turn = [
|
|
{"role": "user", "content": "Run both tools."},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "first_tool",
|
|
"arguments": json.dumps({"x": 1}),
|
|
},
|
|
},
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "second_tool",
|
|
"arguments": json.dumps({"y": 2}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_a", "name": "first_tool", "content": "a"},
|
|
{"role": "tool", "tool_call_id": "call_b", "name": "second_tool", "content": "b"},
|
|
{"role": "assistant", "content": "Done."},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert [message["role"] for message in batch] == ["user", "assistant", "assistant"]
|
|
assert batch[1]["parts"] == [
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_a",
|
|
"tool_name": "first_tool",
|
|
"tool_input": {"x": 1},
|
|
"tool_output": "a",
|
|
"tool_status": "completed",
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_b",
|
|
"tool_name": "second_tool",
|
|
"tool_input": {"y": 2},
|
|
"tool_output": "b",
|
|
"tool_status": "completed",
|
|
},
|
|
]
|
|
|
|
def test_messages_to_openviking_batch_skips_openviking_recall_tool_results(self):
|
|
for recall_tool_name in ("viking_search", "viking_read", "viking_browse"):
|
|
turn = [
|
|
{"role": "user", "content": "What did we decide about context assembly?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_recall_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": recall_tool_name,
|
|
"arguments": json.dumps({"query": "context assembly decision"}),
|
|
},
|
|
},
|
|
{
|
|
"id": "call_shell_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "shell_command",
|
|
"arguments": json.dumps({"command": "rg preassemble"}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_recall_1",
|
|
"name": recall_tool_name,
|
|
"content": json.dumps({
|
|
"results": [
|
|
{
|
|
"uri": "viking://user/hermes/memories/context",
|
|
"abstract": "Old OpenViking memory content",
|
|
}
|
|
]
|
|
}),
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_shell_1",
|
|
"name": "shell_command",
|
|
"content": "plugins/memory/openviking/__init__.py",
|
|
},
|
|
{"role": "assistant", "content": "We decided to keep sync_turn scoped to ingestion."},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert [message["role"] for message in batch] == ["user", "assistant", "assistant"]
|
|
assert batch[1]["parts"] == [
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call_shell_1",
|
|
"tool_name": "shell_command",
|
|
"tool_input": {"command": "rg preassemble"},
|
|
"tool_output": "plugins/memory/openviking/__init__.py",
|
|
"tool_status": "completed",
|
|
}
|
|
]
|
|
batch_text = json.dumps(batch)
|
|
assert recall_tool_name not in batch_text
|
|
assert "Old OpenViking memory content" not in batch_text
|
|
|
|
def test_messages_to_openviking_batch_empty_tool_id_does_not_drop_other_results(self):
|
|
# A recall tool result that arrives with an empty tool_call_id must not
|
|
# poison the skip set with "" and silently drop unrelated tool results
|
|
# that also lack an id. Empty tool_call_id is reachable in the canonical
|
|
# transcript (agent_runtime_helpers defaults it to "").
|
|
turn = [
|
|
{"role": "user", "content": "What did we decide?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "viking_search",
|
|
"arguments": json.dumps({"query": "decision"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "",
|
|
"name": "viking_search",
|
|
"content": json.dumps({"results": ["recall stuff"]}),
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "",
|
|
"name": "shell_command",
|
|
"content": "important shell output",
|
|
},
|
|
{"role": "assistant", "content": "done"},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
batch_text = json.dumps(batch)
|
|
# The unrelated (empty-id) shell result must survive.
|
|
assert "important shell output" in batch_text
|
|
# The recall tool result must still be excluded.
|
|
assert "recall stuff" not in batch_text
|
|
assert "viking_search" not in batch_text
|
|
|
|
def test_messages_to_openviking_batch_preserves_responses_text_parts(self):
|
|
turn = [
|
|
{"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
|
|
{"role": "assistant", "content": [{"type": "output_text", "text": "answer"}]},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
|
|
|
|
assert batch == [
|
|
{"role": "user", "parts": [{"type": "text", "text": "hello"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "answer"}]},
|
|
]
|
|
|
|
def test_messages_to_openviking_batch_adds_assistant_peer_id_when_requested(self):
|
|
turn = [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "answer"},
|
|
]
|
|
|
|
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(
|
|
turn,
|
|
assistant_peer_id="hermes",
|
|
)
|
|
|
|
assert batch == [
|
|
{"role": "user", "parts": [{"type": "text", "text": "hello"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "answer"}], "peer_id": "hermes"},
|
|
]
|
|
|
|
|
|
class TestOpenVikingRead:
|
|
def test_overview_read_normalizes_uri_and_unwraps_result(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/overview",
|
|
(("uri", "viking://user/hermes"),),
|
|
): {"result": {"content": "overview text"}},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": "viking://user/hermes/.overview.md", "level": "overview"}))
|
|
|
|
assert result["uri"] == "viking://user/hermes/.overview.md"
|
|
assert result["resolved_uri"] == "viking://user/hermes"
|
|
assert result["level"] == "overview"
|
|
assert result["content"] == "overview text"
|
|
assert provider._client.calls == [(
|
|
"/api/v1/content/overview",
|
|
{"uri": "viking://user/hermes"},
|
|
)]
|
|
|
|
def test_full_read_keeps_original_uri(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", "viking://user/hermes/memories/profile.md"),),
|
|
): {"result": "full text"},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": "viking://user/hermes/memories/profile.md", "level": "full"}))
|
|
|
|
assert result["uri"] == "viking://user/hermes/memories/profile.md"
|
|
assert result["resolved_uri"] == "viking://user/hermes/memories/profile.md"
|
|
assert result["level"] == "full"
|
|
assert result["content"] == "full text"
|
|
assert provider._client.calls == [(
|
|
"/api/v1/content/read",
|
|
{"uri": "viking://user/hermes/memories/profile.md"},
|
|
)]
|
|
|
|
def test_read_accepts_uri_batch_and_caps_batch_full_content(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
uris = [
|
|
"viking://user/hermes/memories/a.md",
|
|
"viking://user/hermes/memories/b.md",
|
|
"viking://user/hermes/memories/c.md",
|
|
"viking://user/hermes/memories/d.md",
|
|
]
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", uris[0]),),
|
|
): {"result": {"content": "a" * 3000}},
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", uris[1]),),
|
|
): {"result": {"content": "b content"}},
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", uris[2]),),
|
|
): {"result": {"content": "c content"}},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uris": uris, "level": "full"}))
|
|
|
|
assert result["requested"] == 4
|
|
assert result["returned"] == 3
|
|
assert result["truncated"] is True
|
|
assert [entry["uri"] for entry in result["results"]] == uris[:3]
|
|
assert result["results"][0]["content"].endswith(
|
|
"[... truncated, use a more specific URI or full level]"
|
|
)
|
|
assert len(result["results"][0]["content"]) < 2700
|
|
assert provider._client.calls == [
|
|
("/api/v1/content/read", {"uri": uris[0]}),
|
|
("/api/v1/content/read", {"uri": uris[1]}),
|
|
("/api/v1/content/read", {"uri": uris[2]}),
|
|
]
|
|
|
|
def test_read_deduplicates_uri_batch_and_keeps_errors_per_uri(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
ok_uri = "viking://user/hermes/memories/ok.md"
|
|
bad_uri = "viking://user/hermes/memories/bad.md"
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", ok_uri),),
|
|
): {"result": {"content": "ok content"}},
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", bad_uri),),
|
|
): RuntimeError("read failed"),
|
|
}
|
|
)
|
|
|
|
result = json.loads(
|
|
provider._tool_read({"uris": [ok_uri, ok_uri, bad_uri], "level": "full"})
|
|
)
|
|
|
|
assert result["requested"] == 2
|
|
assert result["returned"] == 2
|
|
assert result["truncated"] is False
|
|
assert result["results"][0]["content"] == "ok content"
|
|
assert result["results"][1] == {
|
|
"uri": bad_uri,
|
|
"level": "full",
|
|
"error": "read failed",
|
|
}
|
|
|
|
def test_overview_file_uri_routes_straight_to_content_read_via_stat_probe(self):
|
|
"""Pre-check via fs/stat: file URIs skip the directory-only endpoint entirely."""
|
|
provider = OpenVikingMemoryProvider()
|
|
file_uri = "viking://user/hermes/memories/entities/mem_abc.md"
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/fs/stat",
|
|
(("uri", file_uri),),
|
|
): {"result": {"isDir": False}},
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", file_uri),),
|
|
): {"result": {"content": "full content"}},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": file_uri, "level": "overview"}))
|
|
|
|
assert result["uri"] == file_uri
|
|
assert result["resolved_uri"] == file_uri
|
|
assert result["level"] == "overview"
|
|
assert result["fallback"] == "content/read"
|
|
assert result["content"] == "full content"
|
|
assert provider._client.calls == [
|
|
("/api/v1/fs/stat", {"uri": file_uri}),
|
|
("/api/v1/content/read", {"uri": file_uri}),
|
|
]
|
|
|
|
def test_overview_dir_uri_skips_stat_when_pseudo_summary(self):
|
|
"""Pseudo-URI path already resolves to dir, so no stat probe needed."""
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/overview",
|
|
(("uri", "viking://user/hermes"),),
|
|
): {"result": "overview"},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": "viking://user/hermes/.overview.md", "level": "overview"}))
|
|
|
|
assert result["content"] == "overview"
|
|
# No fs/stat call — normalization already determined it's a directory.
|
|
assert provider._client.calls == [
|
|
("/api/v1/content/overview", {"uri": "viking://user/hermes"}),
|
|
]
|
|
|
|
def test_overview_directory_uri_uses_stat_probe_then_overview(self):
|
|
"""Non-pseudo directory URI: stat → isDir=True → summary endpoint."""
|
|
provider = OpenVikingMemoryProvider()
|
|
dir_uri = "viking://user/hermes/memories"
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/fs/stat",
|
|
(("uri", dir_uri),),
|
|
): {"result": {"isDir": True}},
|
|
(
|
|
"/api/v1/content/overview",
|
|
(("uri", dir_uri),),
|
|
): {"result": "dir overview"},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": dir_uri, "level": "overview"}))
|
|
|
|
assert result["content"] == "dir overview"
|
|
assert "fallback" not in result
|
|
assert provider._client.calls == [
|
|
("/api/v1/fs/stat", {"uri": dir_uri}),
|
|
("/api/v1/content/overview", {"uri": dir_uri}),
|
|
]
|
|
|
|
def test_overview_file_uri_falls_back_via_exception_when_stat_indeterminate(self):
|
|
"""If fs/stat raises or returns unknown shape, legacy exception fallback still kicks in."""
|
|
provider = OpenVikingMemoryProvider()
|
|
file_uri = "viking://user/hermes/memories/entities/mem_abc.md"
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/fs/stat",
|
|
(("uri", file_uri),),
|
|
): RuntimeError("stat unavailable"),
|
|
(
|
|
"/api/v1/content/overview",
|
|
(("uri", file_uri),),
|
|
): RuntimeError("500 Internal Server Error"),
|
|
(
|
|
"/api/v1/content/read",
|
|
(("uri", file_uri),),
|
|
): {"result": {"content": "fallback full content"}},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_read({"uri": file_uri, "level": "overview"}))
|
|
|
|
assert result["uri"] == file_uri
|
|
assert result["level"] == "overview"
|
|
assert result["fallback"] == "content/read"
|
|
assert result["content"] == "fallback full content"
|
|
assert provider._client.calls == [
|
|
("/api/v1/fs/stat", {"uri": file_uri}),
|
|
("/api/v1/content/overview", {"uri": file_uri}),
|
|
("/api/v1/content/read", {"uri": file_uri}),
|
|
]
|
|
|
|
def test_summary_uri_error_does_not_fallback_and_raises(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/content/overview",
|
|
(("uri", "viking://user/hermes"),),
|
|
): RuntimeError("500 Internal Server Error"),
|
|
}
|
|
)
|
|
|
|
try:
|
|
provider._tool_read({"uri": "viking://user/hermes/.overview.md", "level": "overview"})
|
|
assert False, "Expected summary endpoint error to be raised"
|
|
except RuntimeError:
|
|
pass
|
|
|
|
assert provider._client.calls == [
|
|
("/api/v1/content/overview", {"uri": "viking://user/hermes"}),
|
|
]
|
|
|
|
|
|
class TestOpenVikingAutoRecallPrefetch:
|
|
def test_prefetch_e2e_sends_limit_and_reads_l2_content(self, monkeypatch):
|
|
records = {"searches": [], "reads": [], "headers": []}
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _send_json(self, payload):
|
|
body = json.dumps(payload).encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/health":
|
|
self._send_json({"healthy": True})
|
|
return
|
|
if parsed.path == "/api/v1/content/read":
|
|
query = parse_qs(parsed.query)
|
|
uri = query.get("uri", [""])[0]
|
|
records["reads"].append(uri)
|
|
self._send_json({"result": {"content": "E2E full L2 memory content."}})
|
|
return
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
|
|
records["headers"].append(dict(self.headers))
|
|
if self.path == "/api/v1/search/search":
|
|
records["searches"].append(payload)
|
|
if payload.get("context_type") == "memory":
|
|
self._send_json({
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/e2e-full.md",
|
|
"score": 0.9,
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": "E2E abstract should not be injected.",
|
|
}
|
|
],
|
|
"resources": [],
|
|
}
|
|
})
|
|
else:
|
|
self._send_json({"result": {"memories": [], "resources": []}})
|
|
return
|
|
self.send_error(404)
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
endpoint = f"http://127.0.0.1:{server.server_port}"
|
|
|
|
for key in (
|
|
"OPENVIKING_RECALL_LIMIT",
|
|
"OPENVIKING_RECALL_SCORE_THRESHOLD",
|
|
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
|
|
"OPENVIKING_RECALL_PREFER_ABSTRACT",
|
|
"OPENVIKING_RECALL_RESOURCES",
|
|
"OPENVIKING_API_KEY",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", endpoint)
|
|
monkeypatch.setenv("OPENVIKING_ACCOUNT", "acct")
|
|
monkeypatch.setenv("OPENVIKING_USER", "user")
|
|
monkeypatch.setenv("OPENVIKING_AGENT", "hermes")
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
try:
|
|
provider.initialize("e2e-session")
|
|
block = provider.prefetch("What should we recall?", session_id="e2e-session")
|
|
finally:
|
|
provider.shutdown()
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=3.0)
|
|
|
|
assert block.startswith("## OpenViking Context\n")
|
|
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 len(records["searches"]) == 1
|
|
assert records["searches"][0]["context_type"] == "memory"
|
|
assert records["searches"][0]["session_id"] == "e2e-session"
|
|
assert "target_uri" not in records["searches"][0]
|
|
assert all(payload["limit"] == 24 for payload in records["searches"])
|
|
assert all("top_k" not in payload for payload in records["searches"])
|
|
assert all("mode" not in payload for payload in records["searches"])
|
|
assert all(payload["score_threshold"] == 0 for payload in records["searches"])
|
|
normalized_headers = [
|
|
{key.lower(): value for key, value in headers.items()}
|
|
for headers in records["headers"]
|
|
]
|
|
assert all(headers.get("x-openviking-actor-peer") == "hermes" for headers in normalized_headers)
|
|
assert all(headers.get("x-openviking-account") == "acct" for headers in normalized_headers)
|
|
assert all(headers.get("x-openviking-user") == "user" for headers in normalized_headers)
|
|
|
|
def test_prefetch_searches_current_query_when_no_background_result(self, monkeypatch):
|
|
responses = {
|
|
(
|
|
"/api/v1/search/search",
|
|
"memory",
|
|
"Who is Caroline?",
|
|
"session-test",
|
|
): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/caroline.md",
|
|
"score": 0.9,
|
|
"level": 1,
|
|
"category": "profile",
|
|
"abstract": "Caroline is a transgender woman.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(monkeypatch, responses)
|
|
|
|
block = provider.prefetch("Who is Caroline?", session_id="session-test")
|
|
|
|
assert "Caroline is a transgender woman." in block
|
|
|
|
def test_prefetch_does_not_consume_other_session_query_result(self, monkeypatch):
|
|
responses = {
|
|
(
|
|
"/api/v1/search/search",
|
|
"memory",
|
|
"Who is Caroline?",
|
|
"session-a",
|
|
): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/caroline.md",
|
|
"score": 0.9,
|
|
"level": 1,
|
|
"category": "profile",
|
|
"abstract": "Caroline context should stay scoped.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
(
|
|
"/api/v1/search/search",
|
|
"memory",
|
|
"When did Melanie run a charity race?",
|
|
"session-b",
|
|
): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/melanie-race.md",
|
|
"score": 0.9,
|
|
"level": 1,
|
|
"category": "events",
|
|
"abstract": "Melanie ran the charity race on May 20.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(monkeypatch, responses)
|
|
|
|
first_block = provider.prefetch("Who is Caroline?", session_id="session-a")
|
|
block = provider.prefetch(
|
|
"When did Melanie run a charity race?",
|
|
session_id="session-b",
|
|
)
|
|
|
|
assert "Caroline context should stay scoped." in first_block
|
|
assert "Melanie ran the charity race on May 20." in block
|
|
assert "Caroline context should stay scoped." not in block
|
|
|
|
def test_prefetch_filters_low_score_items_with_local_threshold(self, monkeypatch):
|
|
responses = {
|
|
("/api/v1/search/search", "memory", "What should we recall?", "session-test"): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/keep.md",
|
|
"score": 0.22,
|
|
"level": 1,
|
|
"category": "preferences",
|
|
"abstract": "Keep this relevant memory.",
|
|
},
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/drop.md",
|
|
"score": 0.12,
|
|
"level": 1,
|
|
"category": "preferences",
|
|
"abstract": "Drop this weak memory.",
|
|
},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(monkeypatch, responses)
|
|
|
|
block = wait_prefetch(provider)
|
|
|
|
assert block.startswith("## OpenViking Context\n")
|
|
assert "Keep this relevant memory." in block
|
|
assert "Drop this weak memory." not in block
|
|
search_payloads = [call[2] for call in FakeRecallClient.calls if call[:2] == ("post", "/api/v1/search/search")]
|
|
assert len(search_payloads) == 1
|
|
assert search_payloads[0]["context_type"] == "memory"
|
|
assert "target_uri" not in search_payloads[0]
|
|
assert all(payload["limit"] == 24 for payload in search_payloads)
|
|
assert all("top_k" not in payload for payload in search_payloads)
|
|
assert all("mode" not in payload for payload in search_payloads)
|
|
assert all(payload["score_threshold"] == 0 for payload in search_payloads)
|
|
|
|
def test_prefetch_skips_complete_entries_that_do_not_fit_budget(self, monkeypatch):
|
|
long_memory = "X" * 120
|
|
responses = {
|
|
("/api/v1/search/search", "memory", "What should we recall?", "session-test"): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/too-large.md",
|
|
"score": 0.9,
|
|
"level": 1,
|
|
"category": "memory",
|
|
"abstract": long_memory,
|
|
},
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/small.md",
|
|
"score": 0.8,
|
|
"level": 1,
|
|
"category": "memory",
|
|
"abstract": "Small memory fits.",
|
|
},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(
|
|
monkeypatch,
|
|
responses,
|
|
OPENVIKING_RECALL_MAX_INJECTED_CHARS="90",
|
|
)
|
|
|
|
block = wait_prefetch(provider)
|
|
|
|
assert "Small memory fits." in block
|
|
assert long_memory not in block
|
|
assert "XXX" not in block
|
|
|
|
def test_prefetch_reads_full_l2_content_by_default(self, monkeypatch):
|
|
responses = {
|
|
("/api/v1/search/search", "memory", "What should we recall?", "session-test"): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/full.md",
|
|
"score": 0.9,
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": "Abstract only.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
("/api/v1/content/read", "viking://user/peers/hermes/memories/full.md"): {
|
|
"result": {"content": "Full L2 memory content."}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(monkeypatch, responses)
|
|
|
|
block = wait_prefetch(provider)
|
|
|
|
assert "Full L2 memory content." in block
|
|
assert "Abstract only." not in block
|
|
assert (
|
|
"get",
|
|
"/api/v1/content/read",
|
|
{"uri": "viking://user/peers/hermes/memories/full.md"},
|
|
) in FakeRecallClient.calls
|
|
|
|
def test_prefetch_prefer_abstract_does_not_read_l2_content(self, monkeypatch):
|
|
responses = {
|
|
("/api/v1/search/search", "memory", "What should we recall?", "session-test"): {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/full.md",
|
|
"score": 0.9,
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": "Use the abstract.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(
|
|
monkeypatch,
|
|
responses,
|
|
OPENVIKING_RECALL_PREFER_ABSTRACT="true",
|
|
)
|
|
|
|
block = wait_prefetch(provider)
|
|
|
|
assert "Use the abstract." in block
|
|
assert not any(call[:2] == ("get", "/api/v1/content/read") for call in FakeRecallClient.calls)
|
|
|
|
def test_prefetch_honors_configured_limit_candidate_limit_and_resources(self, monkeypatch):
|
|
responses = {
|
|
("/api/v1/search/search", ("memory", "resource"), "What should we recall?", "session-test"): {
|
|
"result": {
|
|
"memories": [],
|
|
"resources": [
|
|
{
|
|
"uri": "viking://resources/doc.md",
|
|
"score": 0.9,
|
|
"level": 1,
|
|
"category": "resource",
|
|
"abstract": "Resource recall enabled.",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
}
|
|
provider = make_prefetch_provider(
|
|
monkeypatch,
|
|
responses,
|
|
OPENVIKING_RECALL_LIMIT="2",
|
|
OPENVIKING_RECALL_RESOURCES="true",
|
|
)
|
|
|
|
block = wait_prefetch(provider)
|
|
|
|
assert "Resource recall enabled." in block
|
|
search_payloads = [call[2] for call in FakeRecallClient.calls if call[:2] == ("post", "/api/v1/search/search")]
|
|
assert len(search_payloads) == 1
|
|
assert search_payloads[0]["context_type"] == ["memory", "resource"]
|
|
assert "target_uri" not in search_payloads[0]
|
|
assert all(payload["limit"] == 20 for payload in search_payloads)
|
|
assert all("top_k" not in payload for payload in search_payloads)
|
|
assert all("mode" not in payload for payload in search_payloads)
|
|
|
|
def test_queue_prefetch_is_noop_for_openviking_recall(self, monkeypatch):
|
|
provider = make_prefetch_provider(monkeypatch, {})
|
|
|
|
provider.queue_prefetch("What should we recall?", session_id="session-test")
|
|
|
|
assert FakeRecallClient.calls == []
|
|
|
|
|
|
class TestOpenVikingBrowse:
|
|
def test_list_browse_unwraps_and_normalizes_entry_shapes(self):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = FakeVikingClient(
|
|
{
|
|
(
|
|
"/api/v1/fs/ls",
|
|
(("uri", "viking://user/hermes"),),
|
|
): {
|
|
"result": {
|
|
"entries": [
|
|
{"name": "memories", "uri": "viking://user/hermes/memories", "type": "dir"},
|
|
{"rel_path": "profile.md", "uri": "viking://user/hermes/memories/profile.md", "isDir": False, "abstract": "Profile"},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
result = json.loads(provider._tool_browse({"action": "list", "path": "viking://user/hermes"}))
|
|
|
|
assert result["path"] == "viking://user/hermes"
|
|
assert result["entries"] == [
|
|
{"name": "memories", "uri": "viking://user/hermes/memories", "type": "dir", "abstract": ""},
|
|
{"name": "profile.md", "uri": "viking://user/hermes/memories/profile.md", "type": "file", "abstract": "Profile"},
|
|
]
|
|
assert provider._client.calls == [(
|
|
"/api/v1/fs/ls",
|
|
{"uri": "viking://user/hermes"},
|
|
)]
|
|
|
|
|
|
class TestOpenVikingMemoryUriBuilder:
|
|
"""Regression tests for _build_memory_uri — fixes #36969.
|
|
|
|
OpenViking's current memory layout stores peer-scoped memories under
|
|
viking://user/peers/{peer_id}/...
|
|
"""
|
|
|
|
def _make_provider(self, user="alice", agent="coder"):
|
|
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
|
|
p._user = user
|
|
p._agent = agent
|
|
return p
|
|
|
|
def test_uri_layout_includes_peer_segment(self):
|
|
"""URI must contain /peers/{peer_id}/ between user and memories."""
|
|
p = self._make_provider(user="alice", agent="coder")
|
|
uri = p._build_memory_uri("preferences")
|
|
assert uri.startswith("viking://user/peers/coder/memories/preferences/mem_")
|
|
assert uri.endswith(".md")
|
|
|
|
def test_uri_uses_configured_peer_not_default(self):
|
|
"""_agent value is the OpenViking actor peer ID, not hardcoded to 'hermes'."""
|
|
p = self._make_provider(user="alice", agent="research-bot")
|
|
uri = p._build_memory_uri("entities")
|
|
assert "/peers/research-bot/" in uri
|
|
assert "/peers/hermes/" not in uri
|
|
|
|
def test_uri_slug_is_twelve_hex_chars_and_unique(self):
|
|
"""Slug must be 12 hex chars and differ between calls."""
|
|
import re
|
|
p = self._make_provider()
|
|
uri1 = p._build_memory_uri("preferences")
|
|
uri2 = p._build_memory_uri("preferences")
|
|
slug1 = uri1.split("/mem_")[1].replace(".md", "")
|
|
slug2 = uri2.split("/mem_")[1].replace(".md", "")
|
|
assert re.fullmatch(r"[0-9a-f]{12}", slug1)
|
|
assert re.fullmatch(r"[0-9a-f]{12}", slug2)
|
|
assert slug1 != slug2
|
|
|
|
def test_uri_subdir_placed_correctly_for_all_categories(self):
|
|
"""All five category subdirs must appear between memories/ and slug."""
|
|
p = self._make_provider(user="u", agent="a")
|
|
subdirs = ["preferences", "entities", "events", "cases", "patterns"]
|
|
for subdir in subdirs:
|
|
uri = p._build_memory_uri(subdir)
|
|
assert f"/memories/{subdir}/mem_" in uri, (
|
|
f"subdir '{subdir}' not placed correctly in URI: {uri}"
|
|
)
|