hermes-agent/tests/agent/test_gateway_turn_sidecar.py
Soju06 c0c76a4715 perf(gateway): byte-stable session context prompts
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.
2026-07-19 14:58:59 +05:30

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