Skip to content

feat(gateway): authoritative sender attribution in all chat contexts - #13939

Open
0xyg3n wants to merge 2 commits into
NousResearch:mainfrom
0xyg3n:feat/sender-attribution
Open

feat(gateway): authoritative sender attribution in all chat contexts#13939
0xyg3n wants to merge 2 commits into
NousResearch:mainfrom
0xyg3n:feat/sender-attribution

Conversation

@0xyg3n

@0xyg3n 0xyg3n commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Why

Hermes today only attaches a [display_name] prefix when is_shared_multi_user_session() returns True, which requires the non-default group_sessions_per_user: false. That leaves two real-world problems for anyone running a single agent shared by multiple humans (team bot, shared assistant, support channel):

  1. Group chats silently lose sender context the moment they run with the default per-user session isolation flag.
  2. Even when the prefix does fire, it's just the platform display name, which is mutable. Users can rename themselves, two people can share a first name, and display names alone cannot be trusted for identity-sensitive decisions.

For a single agent in a group with, say, three humans, the model has no reliable way to answer "who is asking", which breaks personalization, identity-gated instructions, and any reasoning that depends on the speaker.

What changes

  • New gateway config key attribute_sender (default: true).
  • When enabled and source.user_id is present, every inbound message (DM, group, channel, thread) is prefixed with [from NAME (uid:USER_ID)] <original text> before it reaches the agent.
  • Name resolution order:
    1. Env var HERMES_USER_NAME_<user_id> (operator override, useful for mapping telegram/discord ids to canonical names)
    2. Platform display name (source.user_name)
    3. Literal unknown (the prefix is never omitted once a uid is known, so downstream parsing can be unconditional)
  • A leading [from ... (uid:...)] pasted into a message body is stripped before the canonical prefix is added, so messages can't trivially impersonate another sender.
  • When attribute_sender is false, the legacy behaviour (display-name-only prefix in shared sessions, nothing in DMs) is preserved unchanged.

Why DMs too

The prefix is applied to DMs as well. Two reasons:

  1. Invariance: downstream tooling (logging, context extraction, any parser that wants to know who spoke) can rely on the prefix being present whenever user_id is known, without branching on chat_type.
  2. Multi-user DM scenarios (shared accounts, bot-bridged DMs from another platform) become unambiguous for free.

For users who only ever run single-human DMs and want raw text, attribute_sender: false restores the old behaviour.

Tests

Extends tests/gateway/test_shared_group_sender_prefix.py with:

  • Group attribution with uid
  • DM attribution with uid
  • Env-var name override supersedes display name
  • unknown fallback when no display name is available
  • Impersonation strip (pasted fake [from X (uid:Y)] is removed)
  • Legacy fallback when attribute_sender: false
  • No-op when user_id is missing

Existing tests in the file continue to pass (they don't set user_id, so they exercise the fallback path).

Run:
```
pytest tests/gateway/test_shared_group_sender_prefix.py -v
```

Rollout

  • Default-on so the correctness win lands without a config change.
  • Opt-out via attribute_sender: false for anyone who has prompt-tuned around the current behaviour.
  • No changes to session key construction, isolation semantics, or the group_sessions_per_user / thread_sessions_per_user flags. Attribution and session isolation are now independent concerns.

Prefix every inbound message with `[from NAME (uid:USER_ID)]` so the agent
can always identify the speaker by their immutable platform user_id. The
human-readable name is best-effort; the uid is the source of truth.

Why
---
Hermes today only attaches a `[display_name]` prefix in sessions where
`is_shared_multi_user_session()` returns True, which requires the non-default
`group_sessions_per_user: false`. That leaves two real-world problems:

1. Group chats with multiple humans sharing one agent (a team bot, a shared
   assistant) silently lose sender context the moment they run with the
   default per-user session isolation flag.
2. Even when the prefix fires, it's just the Telegram/Discord display name,
   which is mutable — users can rename themselves, two people can share a
   first name, and display names alone cannot be trusted for identity-
   sensitive decisions.

For a single agent in a group with three humans, the model has no reliable
way to answer "who is asking" — which breaks personalization, identity-
gated instructions, and any reasoning that depends on the speaker.

What changes
------------
* New gateway config key `attribute_sender` (default: True).
* When enabled and `source.user_id` is present, every inbound message —
  DM, group, channel, thread — is prefixed with
  `[from NAME (uid:USER_ID)] <original text>` before it reaches the agent.
* Name resolution order:
  1. Env var `HERMES_USER_NAME_<user_id>` (operator override)
  2. Platform display name (`source.user_name`)
  3. Literal `unknown` (never omit the prefix once a uid is known)
* Any user-supplied leading `[from ... (uid:...)]` is stripped before the
  canonical prefix is added, so messages can't trivially impersonate
  another sender by pasting a fake header.
* When `attribute_sender` is False, the legacy behaviour (display-name-only
  prefix in shared sessions) is preserved unchanged.

Tests
-----
Extends `tests/gateway/test_shared_group_sender_prefix.py` with cases for
DM attribution, group attribution, env-var override, `unknown` fallback,
impersonation stripping, the legacy-fallback path, and the no-op path
when `user_id` is missing. Existing tests continue to pass.
@0xyg3n
0xyg3n force-pushed the feat/sender-attribution branch from ebc9d3c to 36abd44 Compare April 22, 2026 09:17
@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery labels Apr 22, 2026
@xiaoyaner0201

Copy link
Copy Markdown

Reviewed this closely while solving the same problem on a Discord deployment (one agent shared by two family members in the same server). A few notes in support, plus an independent confirmation of the design's cache behavior since that was my main worry before adopting it.

The injection point is the right one for KV-cache safety. The prefix is added to event.text in _prepare_inbound_message_text — i.e. the current inbound turn only, appended at the head of the new user message. It never rewrites history (the comment already notes the prefix applies to the trigger message, not the backfill block). Cross-checked against agent/prompt_caching.py's system_and_3 strategy: breakpoints sit on the system prompt + last 3 non-system messages, and cache hits are prefix-matched. Because attribution lands after the cached prefix (in the freshly-appended turn), it costs nothing — those tokens were going to be uncached anyway. Crucially it does not touch the system prompt, which is where #35147's "show user_id in session context" approach would land and silently bust breakpoint 1 every time a display name changes. Putting identity in the user turn instead of the system block is consistent with the existing cache discipline in this repo (skill commands as user messages, #5146 moving pre_llm_call context to the user message). So: append-only, no rewrite of cached content, zero cache penalty. The design is correct on this axis.

The impersonation strip is the part I didn't expect but matters most. _SENDER_PREFIX_RE.sub("", ..., count=1) before re-prepending the canonical header closes exactly the spoof from #21574 (one user pasting [from OtherUser (uid:...)] to impersonate). Worth keeping that test prominent.

HERMES_USER_NAME_<user_id> is the actual operator-facing win. On Discord, message.author.display_name is the server nickname — mutable per-guild, and not what an operator's identity rules key on. Mapping the stable numeric ID to a canonical name via env var is what makes "tell these two people apart reliably" work in practice. This is the lightweight subset of the closed #22085 registry that most single-shared-agent setups actually need.

One small thing for consideration: since the prefix now fires on DMs too (the invariance argument is reasonable), single-human-DM users who don't opt out will see [from X (uid:...)] on every turn. The attribute_sender: false escape hatch covers it, but it might be worth a one-line note in the config comment that DMs are included by default, so the behavior change is discoverable without reading the PR.

Relates to #35147 and #32417 (both still open, both asking for stable-ID attribution this PR already delivers). +1 to merging.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling per-message sender attribution; current main still has the display-name-only shared-session prefix at gateway/run.py:10363-10369, so the underlying gap remains.

Problems

  • gateway/run.py:5449 uses only source.user_id, but current session identity deliberately prefers source.user_id_alt (gateway/session.py:935). Signal populates that alternate UUID at gateway/platforms/signal.py:706-708; this does not yet deliver the claimed authoritative identity across platforms.
  • gateway/run.py:5454 introduces HERMES_USER_NAME_<user_id> as user-facing behavioral configuration. AGENTS.md:102-106 requires this class of setting to live in config.yaml.
  • The added tests cover only Telegram-style user_id values (tests/gateway/test_shared_group_sender_prefix.py:82-239), not alternate-ID precedence or config loading. The new global default also needs documentation alongside website/docs/user-guide/configuration.md:1648.

Suggested changes

  • Use the existing user_id_alt or user_id identity precedence and add Signal/Feishu coverage.
  • Move name overrides to documented, platform-scoped config.yaml data and add loader coverage.

Automated hermes-sweeper review.

Comment thread gateway/run.py
#
# Set ``attribute_sender: false`` in gateway config to restore the
# legacy behaviour (display-name-only prefix, shared sessions only).
if getattr(self.config, "attribute_sender", True) and source.user_id:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please resolve the same canonical participant identity used by build_session_key (source.user_id_alt or source.user_id; gateway/session.py:935). Signal provides a stable UUID in user_id_alt (gateway/platforms/signal.py:706-708), so this condition and emitted value otherwise fail the PR's authoritative-ID guarantee on a supported platform.

Comment thread gateway/run.py
# prevent trivial impersonation by pasting a fake ``[from … ]``
# header into the message body.
message_text = _SENDER_PREFIX_RE.sub("", message_text, count=1)
_env_name = os.environ.get(f"HERMES_USER_NAME_{source.user_id}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a new user-facing non-secret HERMES_* configuration mechanism. The repository policy in AGENTS.md:102-106 requires behavioral settings to use config.yaml; move canonical-name overrides into documented config data instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants