mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
The per-message ephemeral context prompt re-renders every turn, and any byte change (Discord auto-thread rename, reset notes, voice channel state) both breaks the provider prompt-cache prefix at the head of every request and changes the gateway agent-cache signature, forcing a full agent rebuild per message. Pin the rendered block per session keyed by a hash of exactly the fields it renders, so only a real input change (rename, topic edit, /sethome, redact_pii flip) re-renders; deliver one-shot per-turn facts (auto-reset note, first-contact intro, voice-channel changes) on the current user message via the api_content sidecar instead of the system prompt; sort get_connected_platforms for byte-stable ordering.
207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
"""Gateway must-deliver notes on the current user message.
|
|
|
|
The gateway relocates per-turn volatile facts OUT of the ephemeral system
|
|
prompt — auto-reset notes, the first-contact intro, voice-channel changes —
|
|
and stages them on ``agent._gateway_turn_context_notes``.
|
|
``build_turn_context`` consumes them once and delivers them through the same
|
|
api_content sidecar channel as plugin context (string content), or as an
|
|
appended text part on multimodal (list) content, where the string sidecar
|
|
cannot apply and the fact would otherwise silently drop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from agent.turn_context import (
|
|
append_notes_to_multimodal_content,
|
|
build_turn_context,
|
|
compose_user_api_content,
|
|
consume_gateway_turn_context_notes,
|
|
)
|
|
|
|
|
|
class _FakeTodoStore:
|
|
def has_items(self):
|
|
return True
|
|
|
|
|
|
class _FakeGuardrails:
|
|
def reset_for_turn(self):
|
|
pass
|
|
|
|
|
|
class _FakeAgent:
|
|
"""Minimal stand-in covering only what the prologue touches
|
|
(mirrors tests/agent/test_api_content_sidecar.py)."""
|
|
|
|
def __init__(self):
|
|
self.session_id = "sess-1"
|
|
self.model = "test/model"
|
|
self.provider = "openrouter"
|
|
self.base_url = "https://openrouter.ai/api/v1"
|
|
self.api_key = "sk-x"
|
|
self.api_mode = "chat_completions"
|
|
self.platform = "discord"
|
|
self.quiet_mode = True
|
|
self.max_iterations = 90
|
|
self.tools = []
|
|
self.valid_tool_names = set()
|
|
self._skip_mcp_refresh = True
|
|
self.compression_enabled = False
|
|
self.context_compressor = types.SimpleNamespace(
|
|
protect_first_n=2, protect_last_n=2
|
|
)
|
|
self._cached_system_prompt = "SYSTEM"
|
|
self._memory_store = None
|
|
self._memory_manager = None
|
|
self._memory_nudge_interval = 0
|
|
self._turns_since_memory = 0
|
|
self._user_turn_count = 0
|
|
self._todo_store = _FakeTodoStore()
|
|
self._tool_guardrails = _FakeGuardrails()
|
|
self._compression_warning = None
|
|
self._interrupt_requested = False
|
|
self._memory_write_origin = "assistant_tool"
|
|
self._stream_context_scrubber = None
|
|
self._stream_think_scrubber = None
|
|
|
|
def _ensure_db_session(self):
|
|
pass
|
|
|
|
def _restore_primary_runtime(self):
|
|
pass
|
|
|
|
def _cleanup_dead_connections(self):
|
|
return False
|
|
|
|
def _emit_status(self, _msg):
|
|
pass
|
|
|
|
def _replay_compression_warning(self):
|
|
pass
|
|
|
|
def _hydrate_todo_store(self, *_a, **_k):
|
|
pass
|
|
|
|
def _safe_print(self, *_a, **_k):
|
|
pass
|
|
|
|
def _persist_session(self, messages, _history=None):
|
|
pass
|
|
|
|
|
|
def _build(agent, **overrides):
|
|
kwargs = dict(
|
|
agent=agent,
|
|
user_message="hello",
|
|
system_message=None,
|
|
conversation_history=None,
|
|
task_id=None,
|
|
stream_callback=None,
|
|
persist_user_message=None,
|
|
restore_or_build_system_prompt=lambda *a, **k: None,
|
|
install_safe_stdio=lambda: None,
|
|
sanitize_surrogates=lambda s: s,
|
|
summarize_user_message_for_log=lambda s: str(s),
|
|
set_session_context=lambda _sid: None,
|
|
set_current_write_origin=lambda _o: None,
|
|
ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *a, **k: None),
|
|
)
|
|
kwargs.update(overrides)
|
|
return build_turn_context(**kwargs)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_runtime_main():
|
|
with patch("agent.auxiliary_client.set_runtime_main", lambda *a, **k: None):
|
|
yield
|
|
|
|
|
|
RESET_NOTE = (
|
|
"[System note: The user's previous session expired due to inactivity. "
|
|
"This is a fresh conversation with no prior context.]"
|
|
)
|
|
VC_NOTE = "[Voice channel now: dev-vc (2 members)]"
|
|
|
|
|
|
class TestConsumeIsOneShot:
|
|
def test_consume_clears_the_attribute(self):
|
|
agent = _FakeAgent()
|
|
agent._gateway_turn_context_notes = RESET_NOTE
|
|
assert consume_gateway_turn_context_notes(agent) == RESET_NOTE
|
|
assert consume_gateway_turn_context_notes(agent) == ""
|
|
|
|
def test_absent_attribute_is_empty(self):
|
|
assert consume_gateway_turn_context_notes(_FakeAgent()) == ""
|
|
|
|
def test_non_string_value_is_empty(self):
|
|
agent = _FakeAgent()
|
|
agent._gateway_turn_context_notes = ["not-a-string"]
|
|
assert consume_gateway_turn_context_notes(agent) == ""
|
|
|
|
|
|
class TestStringContentSidecarDelivery:
|
|
def test_notes_ride_the_api_content_sidecar(self):
|
|
"""String user message: the note lands in the API copy only — the
|
|
stored content stays clean and the sidecar persists the exact sent
|
|
bytes (replay keeps them byte-stable in history)."""
|
|
agent = _FakeAgent()
|
|
agent._gateway_turn_context_notes = RESET_NOTE
|
|
with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
|
|
ctx = _build(agent)
|
|
msg = ctx.messages[ctx.current_turn_user_idx]
|
|
assert msg["content"] == "hello"
|
|
assert msg["api_content"] == "hello\n\n" + RESET_NOTE
|
|
# The composed bytes match what conversation_loop would send.
|
|
assert msg["api_content"] == compose_user_api_content(
|
|
"hello", ctx.ext_prefetch_cache, ctx.plugin_user_context
|
|
)
|
|
# Consumed: a later turn on the same cached agent replays nothing.
|
|
assert agent._gateway_turn_context_notes == ""
|
|
|
|
def test_notes_append_after_plugin_context(self):
|
|
agent = _FakeAgent()
|
|
agent._gateway_turn_context_notes = VC_NOTE
|
|
with patch(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
return_value=[{"context": "PLUGIN-CTX"}],
|
|
):
|
|
ctx = _build(agent)
|
|
msg = ctx.messages[ctx.current_turn_user_idx]
|
|
assert msg["api_content"] == "hello\n\nPLUGIN-CTX\n\n" + VC_NOTE
|
|
|
|
def test_no_notes_means_no_stamp(self):
|
|
agent = _FakeAgent()
|
|
with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
|
|
ctx = _build(agent)
|
|
assert "api_content" not in ctx.messages[ctx.current_turn_user_idx]
|
|
|
|
|
|
class TestMultimodalFallback:
|
|
def test_notes_appended_as_text_part_on_list_content(self):
|
|
"""Multimodal turns can't take the string sidecar
|
|
(compose_user_api_content returns None for lists) — the must-deliver
|
|
fact is appended as a durable text part instead of dropping."""
|
|
agent = _FakeAgent()
|
|
agent._gateway_turn_context_notes = RESET_NOTE
|
|
content = [
|
|
{"type": "text", "text": "look at this"},
|
|
{"type": "image_url", "image_url": {"url": "https://x/img.png"}},
|
|
]
|
|
with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
|
|
ctx = _build(agent, user_message=content)
|
|
msg = ctx.messages[ctx.current_turn_user_idx]
|
|
assert msg["content"][-1] == {"type": "text", "text": RESET_NOTE}
|
|
# No string sidecar for list content.
|
|
assert "api_content" not in msg
|
|
|
|
def test_helper_appends_only_to_lists(self):
|
|
content = [{"type": "text", "text": "hi"}]
|
|
assert append_notes_to_multimodal_content(content, "NOTE") is True
|
|
assert content[-1] == {"type": "text", "text": "NOTE"}
|
|
assert append_notes_to_multimodal_content("string", "NOTE") is False
|
|
assert append_notes_to_multimodal_content(content, "") is False
|