Strip injected skill content from memory provider inputs

NousResearch/hermes-agent · cluster 4889 · agent

Data as of

Nothing open on GitHub. Fix merged in #4940. Check it covers your case.

The short answer

0open issues
0open PRs
8items grouped
Known?
Yes. 8 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #4940. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bugs and fixes address framework-injected skill scaffolding text leaking into memory sync, prefetch, and search operations across providers including Honcho and OpenViking. The issues cause polluted memory derivation and oversized or irrelevant queries when skill invocation content is passed as user speech.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4940 fix: use clean user message for all memory provider operations

    merged to main

    files: run_agent.py

    Show the diff
    • run_agent.py
      @@ -6689,10 +6689,12 @@ def run_conversation(
               # External memory provider: prefetch once before the tool loop.
               # Reuse the cached result on every iteration to avoid re-calling
               # prefetch_all() on each tool call (10 tool calls = 10x latency + cost).
      +        # Use original_user_message (clean input) — user_message may contain
      +        # injected skill content that bloats / breaks provider queries.
               _ext_prefetch_cache = ""
               if self._memory_manager:
                   try:
      -                _query = user_message if isinstance(user_message, str) else ""
      +                _query = original_user_message if isinstance(original_user_message, str) else ""
                       _ext_prefetch_cache = self._memory_manager.prefetch_all(_query) or ""
                   except Exception:
                       pass
      @@ -8666,11 +8668,13 @@ def _stop_spinner():
                   _should_review_skills = True
                   self._iters_since_skill = 0
       
      -        # External memory provider: sync the completed turn + queue next prefetch
      -        if self._memory_manager and final_response and user_message:
      +        # External memory provider: sync the completed turn + queue next prefetch.
      +        # Use original_user_message (clean input) — user_message may contain
      +        # injected skill content that bloats / breaks provider queries.
      +        if self._memory_manager and final_response and original_user_message:
                   try:
      -                self._memory_manager.sync_all(user_message, final_response)
      -                self._memory_manager.queue_prefetch_all(user_message)
      +                self._memory_manager.sync_all(original_user_message, final_response)
      +                self._memory_manager.queue_prefetch_all(original_user_message)
                   except Exception:
                       pass
       
  • #47311 fix(memory): strip skill scaffolding for all providers (generalizes #32663)

    merged to main

    files: agent/memory_manager.py, agent/skill_commands.py, plugins/memory/openviking/__init__.py, tests/agent/test_memory_skill_scaffolding.py, tests/openviking_plugin/test_openviking.py, tests/run_agent/test_memory_sync_interrupted.py

    Show the diff
    • agent/memory_manager.py
      @@ -33,6 +33,7 @@
       from typing import Any, Dict, List, Optional
       
       from agent.memory_provider import MemoryProvider
      +from agent.skill_commands import extract_user_instruction_from_skill_message
       from tools.registry import tool_error
       
       logger = logging.getLogger(__name__)
      @@ -430,16 +431,37 @@ def build_system_prompt(self) -> str:
       
           # -- Prefetch / recall ---------------------------------------------------
       
      +    @staticmethod
      +    def _strip_skill_scaffolding(text: str) -> Optional[str]:
      +        """Return memory-worthy user text, or None to skip the turn.
      +
      +        When a user invokes a /skill or /bundle, Hermes expands the turn into
      +        a model-facing message that embeds the entire skill body. Feeding that
      +        verbatim to memory providers pollutes their stores/embeddings with
      +        prompt scaffolding instead of what the user actually asked. We recover
      +        just the user's instruction here, once, for every provider — so this
      +        is fixed for the whole provider fan-out, not per backend.
      +
      +        - Non-skill messages pass through unchanged.
      +        - Skill turns with a user instruction return that instruction.
      +        - Bare skill invocations (no instruction) return None → callers skip
      +          the turn, since there is no user content worth remembering.
      +        """
      +        return extract_user_instruction_from_skill_message(text)
      +
           def prefetch_all(self, query: str, *, session_id: str = "") -> str:
               """Collect prefetch context from all providers.
       
               Returns merged context text labeled by provider. Empty providers
               are skipped. Failures in one provider don't block others.
               """
      +        clean_query = self._strip_skill_scaffolding(query)
      +        if not clean_query:
      +            return ""
               parts = []
               for provider in self._providers:
                   try:
      -                result = provider.prefetch(query, session_id=session_id)
      +                result = provider.prefetch(clean_query, session_id=session_id)
                       if result and result.strip():
                           parts.append(result)
                   except Exception as e:
      @@ -460,10 +482,14 @@ def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
               if not providers:
                   return
       
      +        clean_query = self._strip_skill_scaffolding(query)
      +        if not clean_query:
      +            return
      +
               def _run() -> None:
                   for provider in providers:
                       try:
      -                    provider.queue_prefetch(query, session_id=session_id)
      +                    provider.queue_prefetch(clean_query, session_id=session_id)
                       except Exception as e:
                           logger.debug(
                               "Memory provider '%s' queue_prefetch failed (non-fatal): %s",
      @@ -515,6 +541,11 @@ def sync_all(
               if not providers:
                   return
       
      +        clean_user_content = self._strip_skill_scaffolding(user_content)
      +        if not clean_user_content:
      +            return
      +        user_content = clean_user_content
      +
               def _run() -> None:
                   for provider in providers:
                       try:
    • agent/skill_commands.py
      @@ -26,6 +26,91 @@
       _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
       _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
       
      +# ---------------------------------------------------------------------------
      +# Skill-scaffolding markers and the canonical extractor.
      +#
      +# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
      +# model-facing message that embeds the full skill body plus scaffolding. That
      +# expanded text is what flows into the agent loop — and into memory providers
      +# via MemoryManager. Providers that store or embed the raw user turn (mem0,
      +# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
      +# otherwise capture the entire skill body instead of what the user actually
      +# asked. ``extract_user_instruction_from_skill_message`` recovers just the
      +# user's instruction so memory stays clean.
      +#
      +# These markers MUST stay byte-identical to the builders below
      +# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
      +# agent/skill_bundles.py). They are co-located with the single-skill builder
      +# on purpose, and the bundle markers are asserted against the bundle builder in
      +# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
      +# ---------------------------------------------------------------------------
      +_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
      +_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
      +_SINGLE_SKILL_INSTRUCTION = (
      +    "The user has provided the following instruction alongside the skill invocation: "
      +)
      +_RUNTIME_NOTE = "\n\n[Runtime note:"
      +_BUNDLE_MARKER = " skill bundle,"
      +_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
      +_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
      +
      +
      +def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
      +    """Recover the user's instruction from a slash-skill-expanded turn.
      +
      +    Returns:
      +        - The original string unchanged when it is NOT skill scaffolding
      +          (a normal user message passes straight through).
      +        - The extracted user instruction when the scaffolding carried one.
      +        - ``None`` when the content is skill scaffolding with no user
      +          instruction (i.e. a bare ``/skill`` invocation). Callers that feed
      +          memory providers should skip the turn in that case — there is no
      +          user content worth storing.
      +    """
      +    if not isinstance(content, str):
      +        return None
      +
      +    if not content.startswith(_SKILL_INVOCATION_PREFIX):
      +        return content
      +
      +    if _BUNDLE_MARKER in content:
      +        return _extract_bundle_user_instruction(content)
      +
      +    if _SINGLE_SKILL_MARKER in content:
      +        return _extract_single_skill_user_instruction(content)
      +
      +    return None
      +
      +
      +def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
      +    # Single-skill format appends the user instruction after the skill body, so
      +    # the last occurrence is the user-provided one; the body may quote this text.
      +    marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
      +    if marker_idx < 0:
      +        return None
      +
      +    instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
      +    runtime_idx = instruction.find(_RUNTIME_NOTE)
      +    if runtime_idx >= 0:
      +        instruction = instruction[:runtime_idx]
      +    instruction = instruction.strip()
      +    return instruction or None
      +
      +
      +def _extract_bundle_user_instruction(message: str) -> Optional[str]:
      +    # Bundle format puts the user instruction before the loaded skills, so the
      +    # first occurrence is the user-provided one.
      +    marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
      +    if marker_idx < 0:
      +        return None
      +
      +    instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
      +    first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
      +    if first_skill_idx >= 0:
      +        instruction = instruction[:first_skill_idx]
      +    instruction = instruction.strip()
      +    return instruction or None
      +
       
       def _resolve_skill_commands_platform() -> Optional[str]:
           """Return the current platform scope used for disabled-skill filtering.
    • plugins/memory/openviking/__init__.py
      @@ -39,6 +39,7 @@
       from urllib.request import url2pathname
       
       from agent.memory_provider import MemoryProvider
      +from agent.skill_commands import extract_user_instruction_from_skill_message
       from tools.registry import tool_error
       
       logger = logging.getLogger(__name__)
      @@ -67,6 +68,19 @@
       }
       
       
      +def _derive_openviking_user_text(content: Any) -> str:
      +    """Strip Hermes slash-skill scaffolding before sending content to OpenViking.
      +
      +    Defense-in-depth: MemoryManager already strips skill scaffolding for the
      +    whole provider fan-out (see ``MemoryManager._strip_skill_scaffolding``), so
      +    in normal operation this receives already-clean text and passes it through
      +    unchanged. It stays here so OpenViking is correct if its hooks are ever
      +    invoked outside the manager. Delegates to the canonical extractor in
      +    ``agent.skill_commands`` — no duplicated marker literals, no drift risk.
      +    """
      +    return extract_user_instruction_from_skill_message(content) or ""
      +
      +
       # ---------------------------------------------------------------------------
       # Process-level atexit safety net — ensures pending sessions are committed
       # even if shutdown_memory_provider is never called (e.g. gateway crash,
      @@ -531,6 +545,7 @@ def prefetch(self, query: str, *, session_id: str = "") -> str:
       
           def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
               """Fire a background search to pre-load relevant context."""
      +        query = _derive_openviking_user_text(query)
               if not self._client or not query:
                   return
       
      @@ -570,6 +585,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
               if not self._client:
                   return
       
      +        user_content = _derive_openviking_user_text(user_content)
      +        if not user_content:
      +            return
      +
               self._turn_count += 1
       
               def _sync():
    • tests/agent/test_memory_skill_scaffolding.py
      @@ -0,0 +1,161 @@
      +"""MemoryManager strips slash-skill scaffolding for every provider.
      +
      +When a user invokes a /skill or /bundle, Hermes expands the turn into a
      +model-facing message that embeds the full skill body. Feeding that verbatim to
      +memory providers pollutes their stores/embeddings with prompt scaffolding
      +instead of what the user actually asked. The strip lives once in MemoryManager
      +so it covers the whole provider fan-out — not per backend.
      +
      +See: agent.skill_commands.extract_user_instruction_from_skill_message and
      +MemoryManager._strip_skill_scaffolding.
      +"""
      +
      +from agent.memory_manager import MemoryManager
      +from agent.memory_provider import MemoryProvider
      +from agent.skill_commands import extract_user_instruction_from_skill_message
      +
      +
      +_SINGLE_SKILL_TURN = (
      +    '[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"
      +)
      +
      +_BUNDLE_TURN = (
      +    '[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."
      +)
      +
      +_BARE_SKILL_TURN = (
      +    '[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, no user instruction."
      +)
      +
      +
      +class _RecordingProvider(MemoryProvider):
      +    """Captures exactly what user text each fan-out method received."""
      +
      +    _name = "recording"
      +
      +    def __init__(self):
      +        self.prefetched = []
      +        self.queued = []
      +        self.synced = []
      +
      +    @property
      +    def name(self) -> str:
      +        return self._name
      +
      +    def initialize(self, session_id: str = "", **kwargs) -> None:
      +        pass
      +
      +    def is_available(self) -> bool:
      +        return True
      +
      +    def system_prompt_block(self) -> str:
      +        return ""
      +
      +    def prefetch(self, query, *, session_id: str = "") -> str:
      +        self.prefetched.append(query)
      +        return ""
      +
      +    def queue_prefetch(self, query, *, session_id: str = "") -> None:
      +        self.queued.append(query)
      +
      +    def sync_turn(self, user_content, assistant_content, *, session_id: str = "", messages=None) -> None:
      +        self.synced.append(user_content)
      +
      +    def get_tool_schemas(self):
      +        return []
      +
      +
      +def _manager_with_recorder():
      +    mgr = MemoryManager()
      +    provider = _RecordingProvider()
      +    mgr.add_provider(provider)
      +    return mgr, provider
      +
      +
      +class TestExtractUserInstruction:
      +    def test_non_string_returns_none(self):
      +        assert extract_user_instruction_from_skill_message(None) is None
      +        assert extract_user_instruction_from_skill_message(123) is None
      +        assert extract_user_instruction_from_skill_message([{"text": "hi"}]) is None
      +
      +    def test_plain_message_passes_through(self):
      +        assert extract_user_instruction_from_skill_message("just a message") == "just a message"
      +
      +    def test_single_skill_with_instruction(self):
      +        assert (
      +            extract_user_instruction_from_skill_message(_SINGLE_SKILL_TURN)
      +            == "make a skill for release triage"
      +        )
      +
      +    def test_bundle_with_instruction(self):
      +        assert (
      +            extract_user_instruction_from_skill_message(_BUNDLE_TURN)
      +            == "fix the failing retrieval test"
      +        )
      +
      +    def test_bare_skill_returns_none(self):
      +        assert extract_user_instruction_from_skill_message(_BARE_SKILL_TURN) is None
      +
      +    def test_runtime_note_trimmed_from_single_skill(self):
      +        turn = _SINGLE_SKILL_TURN + "\n\n[Runtime note: in a subagent]"
      +        assert (
      +            extract_user_instruction_from_skill_message(turn)
      +            == "make a skill for release triage"
      +        )
      +
      +
      +class TestMemoryManagerStripsScaffolding:
      +    def test_prefetch_all_strips_single_skill(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.prefetch_all(_SINGLE_SKILL_TURN)
      +        assert provider.prefetched == ["make a skill for release triage"]
      +
      +    def test_prefetch_all_skips_bare_skill(self):
      +        mgr, provider = _manager_with_recorder()
      +        result = mgr.prefetch_all(_BARE_SKILL_TURN)
      +        assert result == ""
      +        assert provider.prefetched == []
      +
      +    def test_queue_prefetch_all_strips_bundle(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.queue_prefetch_all(_BUNDLE_TURN)
      +        mgr.flush_pending(timeout=5.0)
      +        assert provider.queued == ["fix the failing retrieval test"]
      +
      +    def test_queue_prefetch_all_skips_bare_skill(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.queue_prefetch_all(_BARE_SKILL_TURN)
      +        mgr.flush_pending(timeout=5.0)
      +        assert provider.queued == []
      +
      +    def test_sync_all_strips_single_skill(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.sync_all(_SINGLE_SKILL_TURN, "Done.")
      +        mgr.flush_pending(timeout=5.0)
      +        assert provider.synced == ["make a skill for release triage"]
      +
      +    def test_sync_all_skips_bare_skill(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.sync_all(_BARE_SKILL_TURN, "Done.")
      +        mgr.flush_pending(timeout=5.0)
      +        assert provider.synced == []
      +
      +    def test_plain_message_passes_through_unchanged(self):
      +        mgr, provider = _manager_with_recorder()
      +        mgr.sync_all("what's the weather", "Sunny.")
      +        mgr.flush_pending(timeout=5.0)
      +        assert provider.synced == ["what's the weather"]
    • tests/openviking_plugin/test_openviking.py
      @@ -2,9 +2,26 @@
       
       import json
       
      +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
      @@ -17,6 +34,24 @@ def get(self, path, params=None, **kwargs):
                   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": []}}
      +
       
       class TestOpenVikingSummaryUriNormalization:
           def test_normalize_summary_uri_maps_pseudo_files_to_parent_directory(self):
      @@ -26,6 +61,196 @@ def test_normalize_summary_uri_maps_pseudo_files_to_parent_directory(self):
               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_queue_prefetch_searches_only_slash_skill_user_instruction(self, monkeypatch):
      +        RecordingVikingClient.calls = []
      +        monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
      +        provider = OpenVikingMemoryProvider()
      +        provider._client = 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.queue_prefetch(skill_message)
      +        provider._prefetch_thread.join(timeout=5.0)
      +
      +        assert RecordingVikingClient.calls == [
      +            (
      +                "/api/v1/search/find",
      +                {"query": "make a skill for release triage", "top_k": 5},
      +            )
      +        ]
      +
      +    def test_queue_prefetch_searches_only_skill_bundle_user_instruction(self, monkeypatch):
      +        RecordingVikingClient.calls = []
      +        monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
      +        provider = OpenVikingMemoryProvider()
      +        provider._client = 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.queue_prefetch(skill_message)
      +        provider._prefetch_thread.join(timeout=5.0)
      +
      +        assert RecordingVikingClient.calls == [
      +            (
      +                "/api/v1/search/find",
      +                {"query": "fix the failing retrieval test", "top_k": 5},
      +            )
      +        ]
      +
      +    def test_queue_prefetch_skips_slash_skill_without_user_instruction(self, monkeypatch):
      +        RecordingVikingClient.calls = []
      +        monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
      +        provider = OpenVikingMemoryProvider()
      +        provider._client = 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."
      +        )
      +
      +        provider.queue_prefetch(skill_message)
      +
      +        assert provider._prefetch_thread is None
      +        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 = 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.")
      +        provider._sync_thread.join(timeout=5.0)
      +
      +        assert RecordingVikingClient.calls == [
      +            (
      +                "/api/v1/sessions/session-1/messages",
      +                {"role": "user", "content": "make a skill for release triage"},
      +            ),
      +            (
      +                "/api/v1/sessions/session-1/messages",
      +                {"role": "assistant", "content": "Done."},
      +            ),
      +        ]
      +
      +    def test_sync_turn_skips_slash_skill_without_user_instruction(self, monkeypatch):
      +        RecordingVikingClient.calls = []
      +        monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
      +        provider = OpenVikingMemoryProvider()
      +        provider._client = 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._sync_thread is None
      +        assert RecordingVikingClient.calls == []
      +
      +
       class TestOpenVikingRead:
           def test_overview_read_normalizes_uri_and_unwraps_result(self):
               provider = OpenVikingMemoryProvider()
    • tests/run_agent/test_memory_sync_interrupted.py
      @@ -130,6 +130,39 @@ def test_completed_turn_syncs_messages_when_present(self):
                   messages=messages,
               )
       
      +    def test_completed_skill_turn_keeps_original_message_for_memory_manager(self):
      +        """Provider-specific query shaping belongs inside the provider.
      +
      +        The MemoryManager fan-out contract stays raw so non-OpenViking
      +        providers can decide for themselves whether slash-skill-expanded
      +        content is useful.
      +        """
      +        agent = _bare_agent()
      +        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"
      +        )
      +
      +        agent._sync_external_memory_for_turn(
      +            original_user_message=skill_message,
      +            final_response="Done.",
      +            interrupted=False,
      +        )
      +
      +        agent._memory_manager.sync_all.assert_called_once_with(
      +            skill_message,
      +            "Done.",
      +            session_id="test_session_001",
      +        )
      +        agent._memory_manager.queue_prefetch_all.assert_called_once_with(
      +            skill_message,
      +            session_id="test_session_001",
      +        )
      +
           # --- Edge cases (pre-existing behaviour preserved) ------------------
       
           def test_no_final_response_skips(self):

3 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1

  • #4902 fix: use clean user_message for memory sync/prefetch (fixes #4889) · superseded by another PR → #4940
  • #32663 fix(openviking): sanitize skill memory input · merged via another change → e3adbb5, c2c55c4, #47311
  • #43739 fix(honcho): skip framework-injected skill activation messages in memory sync · done elsewhere → c2c55c4, #43733
Closed issues · 3
  • Closed#4889[Bug]: Honcho dialectic queries fail with oversized `user_message` (skill content injected into memory prefetch)
  • Closed#32662OpenViking memory provider sends slash-skill scaffolding as search text
  • Closed#43733Honcho: injected skill invocation text syncs to memory as user speech, polluting the deriver
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.20: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#4940, #47311
Closed issues
3
Attempts
3 PRs closed without merging
Fix link checked
16 Jun 2026

For your agent

The same facts, typed.