259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""Affinity contract on Hermes Studio's group-chat bridge shape (#96811).
|
|
|
|
Studio's group chat is the reproduction reported on #96811, and it reaches
|
|
Hermes as a LIBRARY rather than through the gateway: its Python bridge
|
|
constructs ``AIAgent(...)`` directly with a fresh physical ``session_id`` for
|
|
every reply, and only then persists the session row.
|
|
|
|
``tests/agent/test_declared_conversation_scope.py`` pins the declaration
|
|
contract on synthetic agent doubles, and
|
|
``test_prompt_cache_scope.py::TestPerResponseRunNonceIsolation`` pins the
|
|
id-level isolation the scope rule may never break. Neither runs the host's
|
|
own construction path, so nothing today would fail if a refactor made that
|
|
path stop honouring a declaration. This suite closes that: it builds a real
|
|
``AIAgent`` in the bridge's order and states exactly what the host adoption
|
|
buys — and what it must not cross.
|
|
|
|
The shape reproduced here:
|
|
|
|
1. ``groupRuntimeSessionId(room, profile, name)`` mints
|
|
``gc_run_<room>_<profile>_<name>`` truncated to 96 characters plus a fresh
|
|
UUID4 hex — a NEW physical id per reply;
|
|
2. the agent is constructed before the row exists (same as Studio's pool);
|
|
the bridge then writes the row, so the resolution reads a landed row;
|
|
3. ``AIAgent(...)`` is constructed with ``platform`` / ``session_id`` /
|
|
``session_db`` and no routing identity of any kind.
|
|
|
|
``groupBridgeSessionId(room, profile, name, sessionSeed, runtimeConfig)`` is
|
|
the value that IS stable across those replies. It already exists host-side,
|
|
already carries the room, profile, agent name and the room-owned
|
|
``sessionSeed``, and is already hashed and length-bounded, so passing it as
|
|
``gateway_session_key`` is the entire adoption.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from agent.prompt_cache_scope import resolve_prompt_cache_scope
|
|
from hermes_state import SessionDB
|
|
from run_agent import AIAgent
|
|
|
|
# The bridge stamps its rows with the ``source`` carried by ``bridge.chat``
|
|
# while ``AIAgent.platform`` is the bridge's own platform name; the two need
|
|
# not match. Keeping them different here is deliberate — the scope has to
|
|
# resolve under the source the row actually landed on.
|
|
BRIDGE_ROW_SOURCE = "api_server"
|
|
BRIDGE_PLATFORM = "hermes_bridge"
|
|
|
|
ROOM = "room-7c1f"
|
|
PROFILE = "default"
|
|
|
|
|
|
def _runtime_session_id(room: str = ROOM, name: str = "Reviewer") -> str:
|
|
"""``groupRuntimeSessionId`` — a new physical id for every reply."""
|
|
prefix = f"gc_run_{room}_{PROFILE}_{name}"[:96]
|
|
return f"{prefix}_{uuid4().hex}"
|
|
|
|
|
|
def _bridge_session_key(
|
|
room: str = ROOM, name: str = "Reviewer", seed: str = "0"
|
|
) -> str:
|
|
"""``groupBridgeSessionId`` — stable for one conversation in one room."""
|
|
raw = f"gc_{room}_{PROFILE}_{name}_{seed}"
|
|
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
|
|
suffix = f"_h_{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
|
|
return f"{safe_prefix[: max(0, 120 - len(suffix))]}{suffix}"
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
session_db = SessionDB(db_path=tmp_path / "state.db")
|
|
try:
|
|
yield session_db
|
|
finally:
|
|
session_db.close()
|
|
|
|
|
|
def _bridge_agent(session_db, session_id: str, *, declared_key: str | None):
|
|
"""Construct an agent the way the Studio bridge pool does."""
|
|
with (
|
|
patch("run_agent.get_tool_definitions", return_value=[]),
|
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
|
patch("run_agent.OpenAI"),
|
|
):
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
quiet_mode=True,
|
|
verbose_logging=False,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
session_db=session_db,
|
|
session_id=session_id,
|
|
platform=BRIDGE_PLATFORM,
|
|
gateway_session_key=declared_key,
|
|
)
|
|
agent.client = MagicMock()
|
|
return agent
|
|
|
|
|
|
def _reply_scope(
|
|
session_db,
|
|
*,
|
|
declared_key: str | None,
|
|
room: str = ROOM,
|
|
name: str = "Reviewer",
|
|
row_source: str = BRIDGE_ROW_SOURCE,
|
|
) -> str:
|
|
"""One Studio reply, in the bridge's own order, returning its scope.
|
|
|
|
Mirrors the real Studio construction order: ``AIAgent(...)`` is built
|
|
first with a fresh physical id, then the bridge persists the row, then
|
|
the scope is resolved against the now-landed row. Constructing the
|
|
agent before persistence is the part the test exists to pin — the
|
|
resolution path must read the row at resolve time, not assume it was
|
|
already there when the agent was instantiated.
|
|
"""
|
|
session_id = _runtime_session_id(room=room, name=name)
|
|
# AIAgent first — same order as the Studio bridge pool.
|
|
agent = _bridge_agent(session_db, session_id, declared_key=declared_key)
|
|
# Then the bridge writes the row, which is when the scope's row reads
|
|
# finally have something to find.
|
|
session_db.create_session(
|
|
session_id=session_id, source=row_source, model="test/model"
|
|
)
|
|
return resolve_prompt_cache_scope(agent)
|
|
|
|
|
|
class TestStudioBridgeAffinity:
|
|
def test_the_undeclared_bridge_shape_rekeys_every_reply(self, db):
|
|
"""The reproduction, stated as a permanent negative control.
|
|
|
|
Hermes must not infer the conversation from the id's syntax — that is
|
|
#79017's collision class — so a bridge that declares nothing keeps a
|
|
per-reply scope by design. This stays green before and after the host
|
|
adopts the key, and it is why the adoption has to happen host-side.
|
|
"""
|
|
scopes = {_reply_scope(db, declared_key=None) for _ in range(3)}
|
|
|
|
assert len(scopes) == 3
|
|
|
|
def test_a_declared_conversation_holds_one_affinity_across_replies(self, db):
|
|
"""Consecutive replies keep one affinity though their ids differ."""
|
|
key = _bridge_session_key()
|
|
|
|
scopes = {_reply_scope(db, declared_key=key) for _ in range(3)}
|
|
|
|
assert len(scopes) == 1
|
|
assert scopes.pop().startswith("gwk_")
|
|
|
|
def test_the_scope_never_carries_the_room_or_member(self, db):
|
|
"""The scope leaves the process verbatim as a provider routing key."""
|
|
scope = _reply_scope(db, declared_key=_bridge_session_key())
|
|
|
|
assert ROOM not in scope
|
|
assert "Reviewer" not in scope
|
|
assert len(scope) <= 64
|
|
|
|
def test_a_different_member_or_room_gets_a_different_affinity(self, db):
|
|
"""Two members of one room, and two rooms, never share a bucket."""
|
|
reviewer = _reply_scope(db, declared_key=_bridge_session_key(name="Reviewer"))
|
|
planner = _reply_scope(
|
|
db, declared_key=_bridge_session_key(name="Planner"), name="Planner"
|
|
)
|
|
other_room = _reply_scope(
|
|
db, declared_key=_bridge_session_key(room="room-99ab"), room="room-99ab"
|
|
)
|
|
|
|
assert len({reviewer, planner, other_room}) == 3
|
|
|
|
def test_a_new_session_seed_rotates_the_affinity(self, db):
|
|
"""Studio's own conversation boundary is the room-owned sessionSeed.
|
|
|
|
A new conversation in the room mints a new seed, and the seed is part
|
|
of the declared key, so the next conversation starts on a cold bucket
|
|
without Hermes having to observe a reset of its own.
|
|
"""
|
|
first = _reply_scope(db, declared_key=_bridge_session_key(seed="0"))
|
|
second = _reply_scope(db, declared_key=_bridge_session_key(seed="1"))
|
|
|
|
assert first != second
|
|
|
|
def test_the_row_source_is_the_authority_not_the_platform(self, db):
|
|
"""Equal keys under different sources never share one scope.
|
|
|
|
The declared key is caller-supplied and may legally repeat across
|
|
hosts sharing one database, and this value leaves the process as a
|
|
provider routing key, so the carrier is qualified by the source the
|
|
row actually landed under.
|
|
"""
|
|
key = _bridge_session_key()
|
|
|
|
as_api_server = _reply_scope(db, declared_key=key, row_source="api_server")
|
|
as_other = _reply_scope(db, declared_key=key, row_source="studio_native")
|
|
|
|
assert as_api_server != as_other
|
|
|
|
def test_a_tool_child_on_the_same_declaration_keeps_its_own_scope(self, db):
|
|
"""#79161 survives this construction path.
|
|
|
|
A tool child inherits nothing from the bridge today, but were a host
|
|
ever to hand one the room's declared key, the row's own fork marker
|
|
still keeps it off the conversation's bucket.
|
|
"""
|
|
key = _bridge_session_key()
|
|
conversation = _reply_scope(db, declared_key=key)
|
|
|
|
child_id = _runtime_session_id()
|
|
child = _bridge_agent(db, child_id, declared_key=key)
|
|
db.create_session(session_id=child_id, source="tool", model="test/model")
|
|
|
|
assert resolve_prompt_cache_scope(child) == child_id
|
|
assert child_id != conversation
|
|
|
|
def test_a_background_review_fork_never_borrows_the_declaration(self, db):
|
|
"""Background review clones the live runtime, declared key included."""
|
|
key = _bridge_session_key()
|
|
session_id = _runtime_session_id()
|
|
agent = _bridge_agent(db, session_id, declared_key=key)
|
|
agent._persist_disabled = True
|
|
db.create_session(
|
|
session_id=session_id, source=BRIDGE_ROW_SOURCE, model="test/model"
|
|
)
|
|
|
|
assert resolve_prompt_cache_scope(agent) == session_id
|
|
|
|
|
|
class TestUndeclaredBridgeIsUnchanged:
|
|
"""Byte-identical behaviour for a bridge that declares nothing."""
|
|
|
|
def test_the_undeclared_shape_resolves_to_the_physical_id(self, db):
|
|
session_id = _runtime_session_id()
|
|
agent = _bridge_agent(db, session_id, declared_key=None)
|
|
db.create_session(
|
|
session_id=session_id, source=BRIDGE_ROW_SOURCE, model="test/model"
|
|
)
|
|
|
|
assert resolve_prompt_cache_scope(agent) == session_id
|
|
|
|
def test_an_undeclared_compression_rotation_still_walks_its_lineage(self, db):
|
|
root = _runtime_session_id()
|
|
db.create_session(session_id=root, source=BRIDGE_ROW_SOURCE, model="test/model")
|
|
db.end_session(root, "compression")
|
|
rotated = _runtime_session_id()
|
|
db.create_session(
|
|
session_id=rotated,
|
|
source=BRIDGE_ROW_SOURCE,
|
|
model="test/model",
|
|
parent_session_id=root,
|
|
)
|
|
agent = _bridge_agent(db, rotated, declared_key=None)
|
|
|
|
assert resolve_prompt_cache_scope(agent) == root
|