mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(gateway): preserve sender attribution in shared group sessions
Generalize shared multi-user session handling so non-thread group sessions (group_sessions_per_user=False) get the same treatment as shared threads: inbound messages are prefixed with [sender name], and the session prompt shows a multi-user note instead of pinning a single **User:** line into the cached system prompt. Before: build_session_key already treated these as shared sessions, but _prepare_inbound_message_text and build_session_context_prompt only recognized shared threads — creating cross-user attribution drift and prompt-cache contamination in shared groups. - Add is_shared_multi_user_session() helper alongside build_session_key() so both the session key and the multi-user branches are driven by the same rules (DMs never shared, threads shared unless thread_sessions_per_user, groups shared unless group_sessions_per_user). - Add shared_multi_user_session field to SessionContext, populated by build_session_context() from config. - Use context.shared_multi_user_session in the prompt builder (label is 'Multi-user thread' when a thread is present, 'Multi-user session' otherwise). - Use the helper in _prepare_inbound_message_text so non-thread shared groups also get [sender] prefixes. Default behavior unchanged: DMs stay single-user, groups with group_sessions_per_user=True still show the user normally, shared threads keep their existing multi-user behavior. Tests (65 passed): - tests/gateway/test_session.py: new shared non-thread group prompt case. - tests/gateway/test_shared_group_sender_prefix.py: inbound preprocessing for shared non-thread groups and default groups.
This commit is contained in:
parent
c5a814b233
commit
04f9ffb792
4 changed files with 135 additions and 16 deletions
|
|
@ -279,6 +279,7 @@ from gateway.session import (
|
|||
build_session_context,
|
||||
build_session_context_prompt,
|
||||
build_session_key,
|
||||
is_shared_multi_user_session,
|
||||
)
|
||||
from gateway.delivery import DeliveryRouter
|
||||
from gateway.platforms.base import (
|
||||
|
|
@ -3789,12 +3790,12 @@ class GatewayRunner:
|
|||
history = history or []
|
||||
message_text = event.text or ""
|
||||
|
||||
_is_shared_thread = (
|
||||
source.chat_type != "dm"
|
||||
and source.thread_id
|
||||
and not getattr(self.config, "thread_sessions_per_user", False)
|
||||
_is_shared_multi_user = is_shared_multi_user_session(
|
||||
source,
|
||||
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
|
||||
thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False),
|
||||
)
|
||||
if _is_shared_thread and source.user_name:
|
||||
if _is_shared_multi_user and source.user_name:
|
||||
message_text = f"[{source.user_name}] {message_text}"
|
||||
|
||||
if event.media_urls:
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class SessionContext:
|
|||
source: SessionSource
|
||||
connected_platforms: List[Platform]
|
||||
home_channels: Dict[Platform, HomeChannel]
|
||||
shared_multi_user_session: bool = False
|
||||
|
||||
# Session metadata
|
||||
session_key: str = ""
|
||||
|
|
@ -166,6 +167,7 @@ class SessionContext:
|
|||
"home_channels": {
|
||||
p.value: hc.to_dict() for p, hc in self.home_channels.items()
|
||||
},
|
||||
"shared_multi_user_session": self.shared_multi_user_session,
|
||||
"session_key": self.session_key,
|
||||
"session_id": self.session_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
|
|
@ -240,18 +242,16 @@ def build_session_context_prompt(
|
|||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
|
||||
# User identity.
|
||||
# In shared thread sessions (non-DM with thread_id), multiple users
|
||||
# contribute to the same conversation. Don't pin a single user name
|
||||
# in the system prompt — it changes per-turn and would bust the prompt
|
||||
# cache. Instead, note that this is a multi-user thread; individual
|
||||
# sender names are prefixed on each user message by the gateway.
|
||||
_is_shared_thread = (
|
||||
context.source.chat_type != "dm"
|
||||
and context.source.thread_id
|
||||
)
|
||||
if _is_shared_thread:
|
||||
# In shared multi-user sessions (shared threads OR shared non-thread groups
|
||||
# when group_sessions_per_user=False), multiple users contribute to the same
|
||||
# conversation. Don't pin a single user name in the system prompt — it
|
||||
# changes per-turn and would bust the prompt cache. Instead, note that
|
||||
# this is a multi-user session; individual sender names are prefixed on
|
||||
# each user message by the gateway.
|
||||
if context.shared_multi_user_session:
|
||||
session_label = "Multi-user thread" if context.source.thread_id else "Multi-user session"
|
||||
lines.append(
|
||||
"**Session type:** Multi-user thread — messages are prefixed "
|
||||
f"**Session type:** {session_label} — messages are prefixed "
|
||||
"with [sender name]. Multiple users may participate."
|
||||
)
|
||||
elif context.source.user_name:
|
||||
|
|
@ -467,6 +467,27 @@ class SessionEntry:
|
|||
)
|
||||
|
||||
|
||||
def is_shared_multi_user_session(
|
||||
source: SessionSource,
|
||||
*,
|
||||
group_sessions_per_user: bool = True,
|
||||
thread_sessions_per_user: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when a non-DM session is shared across participants.
|
||||
|
||||
Mirrors the isolation rules in :func:`build_session_key`:
|
||||
- DMs are never shared.
|
||||
- Threads are shared unless ``thread_sessions_per_user`` is True.
|
||||
- Non-thread group/channel sessions are shared unless
|
||||
``group_sessions_per_user`` is True (default: True = isolated).
|
||||
"""
|
||||
if source.chat_type == "dm":
|
||||
return False
|
||||
if source.thread_id:
|
||||
return not thread_sessions_per_user
|
||||
return not group_sessions_per_user
|
||||
|
||||
|
||||
def build_session_key(
|
||||
source: SessionSource,
|
||||
group_sessions_per_user: bool = True,
|
||||
|
|
@ -1238,6 +1259,11 @@ def build_session_context(
|
|||
source=source,
|
||||
connected_platforms=connected,
|
||||
home_channels=home_channels,
|
||||
shared_multi_user_session=is_shared_multi_user_session(
|
||||
source,
|
||||
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
|
||||
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
|
||||
),
|
||||
)
|
||||
|
||||
if session_entry:
|
||||
|
|
|
|||
|
|
@ -356,6 +356,28 @@ class TestBuildSessionContextPrompt:
|
|||
assert "**User:** Alice" in prompt
|
||||
assert "Multi-user thread" not in prompt
|
||||
|
||||
def test_shared_non_thread_group_prompt_hides_single_user(self):
|
||||
"""Shared non-thread group sessions should avoid pinning one user."""
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
group_sessions_per_user=False,
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_name="Test Group",
|
||||
chat_type="group",
|
||||
user_name="Alice",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Multi-user session" in prompt
|
||||
assert "[sender name]" in prompt
|
||||
assert "**User:** Alice" not in prompt
|
||||
|
||||
def test_dm_thread_shows_user_not_multi(self):
|
||||
"""DM threads are single-user and should show User, not multi-user note."""
|
||||
config = GatewayConfig(
|
||||
|
|
|
|||
70
tests/gateway/test_shared_group_sender_prefix.py
Normal file
70
tests/gateway/test_shared_group_sender_prefix.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner(config: GatewayConfig) -> GatewayRunner:
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = config
|
||||
runner.adapters = {}
|
||||
runner._model = "openai/gpt-4.1-mini"
|
||||
runner._base_url = None
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocess_prefixes_sender_for_shared_non_thread_group_session():
|
||||
runner = _make_runner(
|
||||
GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
group_sessions_per_user=False,
|
||||
)
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_name="Test Group",
|
||||
chat_type="group",
|
||||
user_name="Alice",
|
||||
)
|
||||
event = MessageEvent(text="hello", source=source)
|
||||
|
||||
result = await runner._prepare_inbound_message_text(
|
||||
event=event,
|
||||
source=source,
|
||||
history=[],
|
||||
)
|
||||
|
||||
assert result == "[Alice] hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocess_keeps_plain_text_for_default_group_sessions():
|
||||
runner = _make_runner(
|
||||
GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_name="Test Group",
|
||||
chat_type="group",
|
||||
user_name="Alice",
|
||||
)
|
||||
event = MessageEvent(text="hello", source=source)
|
||||
|
||||
result = await runner._prepare_inbound_message_text(
|
||||
event=event,
|
||||
source=source,
|
||||
history=[],
|
||||
)
|
||||
|
||||
assert result == "hello"
|
||||
Loading…
Add table
Add a link
Reference in a new issue