Skip to content

feat(gateway): per-agent Buzz identities — N agents, N workspace members, one gateway process - #71686

Open
jethac wants to merge 34 commits into
NousResearch:mainfrom
jethac:feat/buzz-multi-agent
Open

feat(gateway): per-agent Buzz identities — N agents, N workspace members, one gateway process#71686
jethac wants to merge 34 commits into
NousResearch:mainfrom
jethac:feat/buzz-multi-agent

Conversation

@jethac

@jethac jethac commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Stacked PR — do not merge before its base. This branch is
feat/single-gateway-multi-agent-rebased (#62944), rebased onto current
main, plus the five wiring commits described below. Review them on top of
#62944.

Builds on the bundled Buzz adapter merged in #73610 (@rob-coco's
#71610, salvaged by @teknium1 — including the scoped identity lock and
_resolve_user_name negative caching). Earlier revisions of this branch
carried rob's adapter commits directly; they were dropped when #73610
landed on main, and the wiring was re-fitted on top of the hardened
adapter (clean apply; the identity lock keys on relay_url:pubkey, so N
per-agent identities in one process take N distinct locks). All credit
for the adapter — including the block/buzz#2897 DM workaround — remains
@rob-coco's; this PR only builds on it.

What

Buzz (Block's Nostr-based human+agent workspace) has a property no other
supported platform has: an identity is just a keypair. No bot registration,
no app review — a new workspace member costs one nsec. That makes it the
first platform where #62944's agent registry can be taken to its logical
conclusion: every agent in the registry can be its own workspace member.

This PR wires that up. Each agent that declares a Buzz credential gets its
own relay connection inside the one gateway process:

  • it appears in the workspace member list under its own key and display name;
  • channel messages dispatch to the agent whose own identity is @mentioned
    (per-agent mention gate, per-agent require_mention);
  • DMs dispatch to the agent whose key they address (per-recipient p-tag
    classification — the fix(tools): treat Slack channel IDs as explicit send_message targets #2897 workaround, applied per identity);
  • replies always leave on the connection the message arrived on, so the
    answer is authored by the member that was addressed — fail-closed: if an
    agent's connection is down, the gateway refuses to answer as anyone else.

Config surface

gateway:
  agents:
    chip:
      home_dir: ~/.hermes/agents/chip
      buzz:
        nsec_env: CHIP_BUZZ_NSEC     # NAME of the env var holding chip's key
    scout:
      home_dir: ~/.hermes/agents/scout
      buzz:
        nsec_env: SCOUT_BUZZ_NSEC
        require_mention: false        # any adapter setting, per agent

Design decisions, and why:

  • agents.<id>.buzz.nsec_env (a name, never a value). This follows the
    existing feat: single gateway, multiple agents — rebased onto current main (supersedes #25660) #62944 idiom (AgentProfile.api_key_env) and the
    gateway.multiplex_profiles secret discipline: config names credentials,
    .env holds them. Resolution order is gateway secret scope / process env
    first, then the agent's own <home_dir>/.env (the agent-registry analogue
    of a profile home). There is deliberately no fallback to the shared
    BUZZ_PRIVATE_KEY
    — a misconfigured agent stays offline rather than
    connecting as another member.
  • Zero schema changes to the registry. load_agent_registry already
    forwards unknown agent keys into AgentProfile.config_overrides, so the
    buzz: block needs no changes to agent/profile.py.
  • Connection identity is the routing decision. Per-agent adapters get
    set_routing_context(routes=[], default_agent=<agent_id>): a message that
    arrived on chip's connection was addressed to chip's member key, so the
    gateway routes: table is not consulted for these adapters (a generic
    {platform: buzz} route must not steal messages from the member they were
    sent to). The select_agent plugin hook still runs. Everything downstream
    is untouched feat: single gateway, multiple agents — rebased onto current main (supersedes #25660) #62944 machinery: source.agent_id → session-key namespace →
    AgentProfile ContextVar → per-agent home/model/toolsets.
  • Same-key collisions are refused at startup (salted fingerprints, same
    scheme as the multiplex same-token guard): one key cannot be two members,
    and two pollers on one key would race per message.
  • Agent-scoped settings precedence. For per-agent connections the
    agent's block wins over process-wide BUZZ_* env vars (N connections in
    one process: a global var can only be a shared default). Legacy
    single-connection precedence (env > extra) is byte-identical when
    agent_id is absent — the adapter suite from feat(gateway): Buzz (Block/Nostr) platform adapter — bundled plugin (salvage #71610) #73610 passes unchanged.

What this inherits from the adapter (#71610 via #73610)

The adapter's polling loop, mention gating/stripping, and the
block/buzz#2897 DM workaround (dms list returns [] for recipients on
some relays; DMs are recovered from channels list and latched by the
structural recipient p-tag) are rob-coco's work and are inherited as-is —
now instantiated once per agent, so each connection classifies DMs against
its own pubkey. The workaround was validated against a live relay in this
PR's integration test: recipient dms list returned [] there too, and the
p-tag path dispatched correctly.

One adapter observation from that live run, reported upstream to #71610 (now tracked against the merged #73610 adapter)
rather than patched here: messages sent with buzz messages send into a DM
conversation carry only the h tag — the structural recipient p-tag that
the classification keys on is added by the Buzz desktop client, not the
relay. CLI-originated DMs therefore never latch. Suggested follow-up in the
adapter: treat "conversation named DM with empty description AND sender ≠
self" as a weaker classification signal, or ask block/buzz to stamp the
p-tag relay-side.

Relationship to multiplex_profiles and #70326

Upstream now has three per-something systems that touch connections and
credentials:

System Unit Credential surface
gateway.multiplex_profiles profile (HERMES_HOME) per-profile .env + secret scopes
#62944 agent registry agent (in-process) api_key_env (LLM) — now also <platform>.nsec_env (this PR)
#70326 tool policy agent/tool n/a (authorization, not identity)

This PR deliberately does not unify them. It reuses their conventions
(name-not-value credentials, fail-closed scopes, shared fingerprint salt so
either system refuses a credential the other has claimed) so that a future
unification — "a connection binding is (owner, platform, credential-ref),
where owner is a profile or an agent" — is a refactor, not a migration.
Flagged in gateway/agent_platforms.py's module docstring. Per-agent tool
policy (#70326) composes orthogonally: this PR decides which agent a
message belongs to; #70326 decides what that agent may do.

Files changed (on top of #62944 + main's #73610 adapter)

  • plugins/platforms/buzz/adapter.pyprivate_key_env resolution
    (fail-closed), agent-scoped settings, per-agent log names
  • gateway/agent_platforms.py — new: binding builder + secret resolution +
    fingerprints
  • gateway/run.py — startup/reconnect/teardown for per-agent adapters
  • gateway/authz_mixin.py — outbound resolution: agent-owned adapter wins,
    declared-but-down binding fails closed
  • gateway/platform_registry.pycreate_adapter(..., skip_check_fn=)
    opt-out for per-agent instances (see "Found in production" below)
  • website/docs/user-guide/messaging/buzz.md — multi-agent section +
    provisioning checklist from the production deployment
  • tests/gateway/test_buzz_multi_agent.py,
    tests/integration/buzz/test_live_relay_multi_agent.py — see below

Found in production: the plugin check_fn gate vs per-agent-only configs

This branch has been deployed to a live gateway running 11 per-agent Buzz
identities with no shared platform credential
(self-hosted relay). That
deployment surfaced one real gap: plugin check_fn hooks are no-arg and
read process-wide env (BUZZ_RELAY_URL + a resolvable shared key), so a
gateway configured only through agents.<id>.buzz.nsec_env had every
per-agent adapter refused at the registry gate — with only the misleading
CLI install hint in the log. The fix: the per-agent startup path passes
skip_check_fn=True to platform_registry.create_adapter — justified
because resolve_binding_secret() has already proven the credential by
name before an adapter is requested, and validate_config (which receives
the instance config and understands private_key_env) still runs, so
genuinely unconfigured instances still fail closed. Covered by two new unit
tests. Everything else in the design survived production contact unchanged:
mention routing, per-recipient DM classification, reply-identity, and the
fail-closed credential rules all behaved as specified under 11 concurrent
identities.

Test evidence

Unit + regression (mock relay only, no network):

$ pytest tests/gateway/test_buzz_adapter.py tests/gateway/test_buzz_multi_agent.py \
    tests/integration/multi_agent tests/gateway/test_agent_routing.py \
    tests/gateway/test_attach_agent_id.py tests/gateway/test_gateway_config_multiagent.py \
    tests/gateway/test_profile_resolution.py tests/gateway/test_profile_overrides.py \
    tests/gateway/test_multiplex_profile_authz.py tests/gateway/test_restart_notification.py \
    tests/gateway/test_api_server_multiplex_secret_scope.py -q
267 passed

(Re-run after the rebase onto main with #73610's bundled adapter.)

Live relay (self-hosted Buzz relay, three freshly minted throwaway member
keys; skip-unless-env — BUZZ_RELAY_URL + three BUZZ_TEST_*_NSEC vars +
buzz/nak binaries — and marked integration, so default CI never runs
it):

$ pytest tests/integration/buzz/test_live_relay_multi_agent.py -m integration -q
1 passed

The test runs two real BuzzAdapter connections in one process and
verifies: @AgentA <nonce> in a shared channel dispatches on A's
connection only, stamped agent_id="agent_a", mention stripped; a p-tagged
DM dispatches on B's connection only (chat_type="dm",
agent_id="agent_b") with recipient dms list empty on this relay (i.e.
the #2897 fallback path is what actually ran); and B's reply is confirmed
on-relay as authored by B's own pubkey.

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) tool/delegate Subagent delegation tool/terminal Terminal execution and process management platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/matrix Matrix adapter (E2EE) platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 26, 2026
@jethac
jethac force-pushed the feat/buzz-multi-agent branch 2 times, most recently from 45dc746 to b0374e4 Compare July 29, 2026 17:39
02356abc and others added 29 commits August 17, 2026 11:54
New hermes agent subcommand group: list, show, add, remove.
Manages agent profiles and routing config in ~/.hermes/config.yaml.

- hermes_cli/agent.py: cmd_agent_list, cmd_agent_show, cmd_agent_add,
  cmd_agent_remove with profile cloning and route cleanup
- hermes_cli/main.py: parser registration
- tests/hermes_cli/test_agent_cli.py: list/show/add/remove coverage,
  route orphan warnings, SOUL summarization
…et_hermes_home

_get_cron_dir() detected a test monkeypatch of the module-level CRON_DIR by
comparing it to a live `get_hermes_home() / "cron"`. That held when
get_hermes_home() was static, but the multi-agent change makes it profile-/
HERMES_HOME-dynamic, so any active-profile or env change made the comparison
mistake CRON_DIR for monkeypatched and return the stale import-time path.

Compare against a frozen import-time default (_CRON_DIR_IMPORT_DEFAULT) instead,
so monkeypatch detection is stable and the dynamic profile path is used
otherwise. Fixes an order-dependent failure in the cron test suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_resolve_delivery_target now carries an agent_id field. Four
TELEGRAM_CRON_THREAD_ID delivery-target assertions added upstream after this
PR's base still asserted the pre-multi-agent dict shape; add the agent_id key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…it adapters

The routing change (route inbound messages via routes table + select_agent
hook) reads self._default_agent_id in _attach_agent_id, but the attribute was
only set in __init__ and set_routing_context. Adapters and test doubles built
via object.__new__ (bypassing __init__) hit an AttributeError at dispatch —
gateway/platforms/base.py:2936 — surfacing as failures across gateway tests
(_DummyAdapter / TelegramAdapter has no attribute '_default_agent_id').

Declare it as a class-level default of "main", matching how _attach_agent_id
already tolerates a missing _gateway_routes/_gateway_ref (both read inside
try/except). Single-agent installs resolve to the legacy "main" agent exactly
as before; instances still override it in __init__ and set_routing_context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… rebase

_notify_session_finalize now resolves and forwards agent_id into the
on_session_finalize plugin hook (single-agent installs pass None). Update the
two single-query finalize assertions to include "agent_id": None, matching the
delivery-target test sync done for cron.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_apply_profile_runtime_overrides read the profile's pinned credential with
os.getenv, bypassing the fail-closed scoped secret reads that multiplexing
relies on (agent/secret_scope.py). Under gateway.multiplex_profiles the
process environment may hold another profile's value, so the bypass risked
handing one agent a different profile's credential.

Route the read through agent.secret_scope.get_secret: scoped turns resolve
from their own secret scope, unscoped reads under multiplexing raise
UnscopedSecretError instead of leaking, and single-profile installs keep the
legacy os.getenv behavior. Add regression tests covering two multiplexed
profiles resolving distinct keys and the unscoped fail-closed path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hermes agent add --from-profile resolved both source and destination as
get_hermes_home() / "profiles" / <name>. When the command runs inside a
named profile (hermes -p <name> agent add), HERMES_HOME is itself
<root>/profiles/<name>, so the clone landed nested at
<root>/profiles/<name>/profiles/<id> — where the root-anchored profile
enumeration (hermes_cli/profiles.py) never looks.

Delegate cloning to the existing root-anchored API,
hermes_cli.profiles.create_profile(clone_from=..., clone_all=True,
no_alias=True), which also brings name validation, the default-profile
source guard, runtime-file stripping, and .env permission tightening for
free. Add a regression test invoking the command from a named profile and
asserting the clone lands under the root profiles/ dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports David Gutowsky's original multi-agent routing wiring (commit
643bbbf, written against v0.17.0) onto jethac's rebased
feat/single-gateway-multi-agent-rebased branch.

Changes:
- Import MessageEvent, MessageType from gateway.platforms.base and
  SessionSource from gateway.session (already present in the current
  tree; v0.17.0 had them in a different location).
- Add _AGENT_CHAT_ID_HEADER / _USER_ID_HEADER / _THREAD_ID_HEADER
  class-level constants on APIServerAdapter.
- Add _read_routing_header(): same CRLF/length guards as the session
  key header.
- Add _resolve_agent_profile(): builds a synthetic SessionSource from
  the three routing headers, stamps agent_id via the shared
  _attach_agent_id hook (declarative routes + select_agent plugin),
  and looks up the AgentProfile in _gateway_ref._agent_registry.
- Add agent_profile: Optional[Any] parameter to _run_agent(); wraps
  the _create_agent + run_conversation block with use_profile() inside
  the executor thread (ContextVars do not cross the thread boundary
  automatically).
- Wire _resolve_agent_profile + agent_profile kwarg into all three
  agent-serving handlers: _handle_chat_completions (both streaming and
  non-streaming paths), _handle_responses (both paths), and
  _handle_runs (_run_and_close + _run_sync, the latter also rebinding
  inside the executor thread).

Deviations from original:
- _run_agent now has a route param (added after v0.17.0 for
  model_routes); agent_profile is appended after it.
- The _run_and_close / _run_sync refactor inside _handle_runs was
  already present in the rebased base; the use_profile wrapping is
  applied at the same points David used on the v0.17.0 shape.
- _bind_api_server_session / clear_session_vars were added after
  v0.17.0; they are preserved in _run_agent, with use_profile wrapping
  only the inner _create_agent + run_conversation block.

Tests: tests/gateway/test_api_server_routing.py — 20 tests covering
header sanitisation, route matching (chat_id/user_id/thread_id,
platform-only catch-all, specificity, CRLF taint, absent headers,
empty registry), ContextVar task isolation, and backward compat.
All 20 pass; existing test_api_server.py (196 tests) and
test_api_server_runs.py (23 tests) remain green.
The api_server routed-run path bound the agent profile's HOME (via
use_profile) but not its fail-closed credential scope. Under
gateway.multiplex_profiles the routed agent's LLM/provider key —
resolved through credential_pool -> get_secret — would then fail
closed (UnscopedSecretError) or read another profile's process-global
value, since provider keys are profile-scoped, not global env.

Install the profile's secret scope alongside its home in both executor
threads, mirroring the base adapter's _profile_runtime_scope, via a
shared _use_profile_and_secret_scope helper (None profile = home no-op).

Tests: TestSecretScopeBinding (helper installs fail-closed scope, two
profiles resolve their own key, None is a no-op) and
TestRunAgentInstallsScope (the run path itself enters the scope at
agent-creation time — mutation-verified to fail if the fix is reverted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fill coverage gaps found auditing the single-gateway/multi-agent PR
against its base. Tests only; no source changes.

- delivery: DeliveryRouter.deliver runs each target inside its routed
  AgentProfile; unknown/absent agent_id falls back to nullcontext
  (cross-agent leak class).
- base adapter: _attach_agent_id resolution order, idempotency,
  select_agent-hook override, and fail-open on resolver/hook/replace
  errors (the routing linchpin, previously untested).
- cron: load_all_jobs/get_all_due_jobs per-profile agent_id stamping
  (main default, one bad profile does not starve siblings);
  _resolve_single_delivery_target propagates job.agent_id onto targets;
  cron-dir stays profile-dynamic while honoring a patched CRON_DIR.
- config/state: GatewayConfig.from_dict agents/routes/default_agent
  parsing + malformed fallbacks; SessionDB.create_session agent_id
  persistence, legacy-column reconcile, first-writer-wins on conflict.
- session: SessionEntry agent_id roundtrip + legacy default;
  build_session_key agent_id-over-profile precedence.
- hooks: post_tool_call / transform_tool_result carry the active
  profile's agent_id (populated path, not just None).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docstring listed "declarative routes -> select_agent hook -> default"
as the resolution order, which reads as routes-first fallback. The code
is `hook_pick or route_match or default or "main"` — the select_agent
hook is always consulted, is handed the route match, and a truthy hook
result OVERRIDES the route. This is the intended design, as stated in
set_routing_context's own docstring ("the select_agent plugin hook ...
overriding the route result"). Only the _attach_agent_id docstring was
stale; correct it to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_insert_session_row uses COALESCE(sessions.agent_id, excluded.agent_id) on
a NOT NULL DEFAULT 'main' column — first-writer-wins.  Both create_session
call sites in api_server (POST /api/sessions and the fork path) omitted
agent_id, so new rows were always written with 'main', permanently locking
out the correct routed value for any later read (e.g. build_session_key's
agent_id-over-profile precedence).

Fix:
- _handle_create_session: resolve the routed agent via _resolve_agent_profile
  (already available on request) and pass agent_id= on the create_session call.
- _handle_fork_session: inherit agent_id from the source session dict (not
  re-resolved from routing headers, which are absent on the fork endpoint —
  re-resolving would fall back to 'main' and break agent isolation for forked
  conversations).

The COALESCE semantics and first-writer-wins behavior are intentional (jethac
has a test pinning them); this commit does not alter _insert_session_row.

Tests (TestSessionCreationPersistsAgentId):
- test_create_session_persists_routed_agent_id: routed create → agent_id='coder'
- test_create_session_defaults_to_main_without_routing_header: no header → 'main'
- test_fork_session_inherits_source_agent_id: fork → inherits parent agent_id

Mutation-verified: removing the agent_id= kwarg in _handle_create_session causes
the first two tests to fail with AssertionError (None != 'coder'/'main').
…n's agent

Route → persist → consume: _handle_create_session now writes the resolved
agent_id to the session row (453a79f); this commit closes the loop by
reading that persisted agent_id at chat time and binding the matching
AgentProfile for every turn.

Changes:
- Add _profile_for_agent_id(agent_id) helper: mirrors the registry lookup
  in _resolve_agent_profile but takes an explicit id instead of re-running
  header-based route resolution.  Returns None for missing/None ids and
  for legacy single-agent installs (no-op sentinel for _run_agent).
- _handle_session_chat: capture session from _get_existing_session_or_404
  (was discarded with _), read session["agent_id"], resolve AgentProfile
  via the new helper, pass agent_profile= to _run_agent.
- _handle_session_chat_stream: same pattern in the outer handler; the
  resolved profile is closed over by _run_and_signal and passed to
  _run_agent there.
- _run_agent already accepts agent_profile and wraps it with
  _use_profile_and_secret_scope (jethac's credential-scope fix) — this
  commit does not re-wrap; it only supplies the value that was missing.

Backward compatibility: sessions with agent_id=None or agent_id not in
the registry yield profile=None → _use_profile_and_secret_scope is a
no-op → existing default-agent behaviour is fully preserved.
…uting

The existing coverage is unit-level: resolve_agent_id, _attach_agent_id,
load_agent_registry, the secret-scope mechanism, _resolve_agent_profile —
each tested in isolation with mocks. None prove a real inbound request
flowing through a running api_server adapter, routed to an agent, executed
under that agent's isolated home + credential scope, with no cross-agent
contamination under load.

This suite drives real HTTP through APIServerAdapter (real routing, real
profile+secret scope, real per-agent home/credential resolution) and stubs
ONLY the LLM turn via a spy _create_agent that captures, inside the executor
thread, what the run actually saw: active agent_id, resolved home, SOUL, and
the resolved provider key. Credential resolution itself stays real, so a
broken scope surfaces as the wrong key/home, not a passing mock.

Scenarios:
- C  credential isolation: each agent resolves its own home-.env key; a
     keyless agent under multiplex never leaks the process-global value.
- B  profile isolation: home getters + SOUL resolve to the routed agent.
- A  routing: header→agent, default fall-through, unmatched→default.
- E  concurrency invariant (P0): K agents × M interleaved concurrent requests,
     every response echoes its own agent/home/key — the ContextVar-across-
     executor-threads guarantee the whole feature rests on.
- H  back-compat: a bare (no agents/routes) install runs as main at the root
     home reading the root .env — legacy single-agent behavior unchanged.

Mutation-verified non-tautological: neutering the agent-scope binder fails
the isolation + concurrency tests.

Stacked on the feature branch (imports the routing code not yet on main);
retarget to main once NousResearch#62944 lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the integration harness to wire the REAL /api/sessions/* route table
(create/get/fork/chat) through the shared TestClient, and add
test_session_identity.py proving the security-adjacent invariant of the
stateful session tier:

- create persists the routed agent_id (first-writer-wins) on the row;
- a session BOUND to agent A runs every turn as A even when a conflicting
  X-Hermes-Chat-Id: B header is re-sent (header cannot hijack the session);
- a header-less chat still runs as the persisted agent (no default fallback);
- fork inherits the parent's agent_id;
- a legacy no-profile session runs at the root home reading the root .env.

Drives real routing + profile/secret scope + SessionDB (state.db); only the
LLM turn is stubbed via the existing spy. Mutation-verified: making
_handle_session_chat re-resolve the agent from the request header instead of
the persisted session flips the hijack/fork tests red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close the loop on the persistent per-agent surface. The existing suite proves
HOME + SOUL + credential isolation; this adds the memory store. Exercise the
REAL tools.memory_tool.MemoryStore (whose get_memory_dir() resolves through
get_hermes_home()) under the SAME _use_profile_and_secret_scope wrapper that
_run_agent enters in the executor thread:

- a memory written as coder lands under profiles/coder/memories/MEMORY.md;
- a research-scoped read never surfaces coder's entry, and vice-versa;
- filesystem ground truth: coder's MEMORY.md exists under its own home and no
  such file exists under research's home.

Mutation-verified: pointing get_memory_dir() at an unscoped shared dir (so the
profile scope no longer redirects the store) flips both tests red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ONE T2 smoke that exercises single-gateway multi-agent routing across a
REAL OS process + executor-thread boundary, which the in-process integration
tier (shared event loop) cannot. Two agents are routed through the real
APIServerAdapter over real aiohttp HTTP inside a real container process; the
spy run persists a run_dump.json under get_hermes_home(), and the host asserts
each agent's dump landed under its OWN scoped home (profiles/<agent>/) with its
own agent_id + credential, and nothing leaked to the root home.

Approach: Docker lane (docker available + cached hermes-agent-harness:latest),
chosen over driving a live gateway for reliability — no multi-minute cold
start, no real port binding. Because the cached image predates this feature,
the worktree is mounted read-only at /host_repo and prepended to sys.path so
the container runs the feature code under test. Skips automatically when no
Docker daemon is present (tests/docker conftest policy).

Mutation-verified: neutering _use_profile_and_secret_scope (never bind the
per-agent scope) makes the containerized run report agent_id=None and the smoke
goes red; revert restores green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some suites (e.g. test_empty_tool_name_loop_dampening) delete every cached
agent.* module from sys.modules to force fresh imports. After that nuke,
agent.profile re-imports with a brand-new ContextVar while this file's
import-time bindings still reference the old module, so set/reset tokens
cross ContextVar instances (ValueError) and an AgentProfile leaks into
every subsequent test in the class.

Re-bind the module's agent.profile symbols to the live module before each
test, and clear the active profile on teardown so a mid-test assertion
failure can never leak state into later tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The multi-agent work moved _create_agent() from the async _run_and_close()
scope into _run_sync(), where it runs under the routed profile's ContextVars
(asyncio's default executor does not copy them). That made `agent` a local of
_run_sync, so when _create_agent() raised before binding it -- exactly what a
provider auth/credential failure does -- the `finally` block's
_clear_turn_process_ownership(agent) raised UnboundLocalError and REPLACED the
original exception.

The consequence is user-visible: /v1/runs reported "cannot access local
variable 'agent' where it is not associated with a value" instead of the
distinguished "⚠️ Provider authentication failed: ..." message that the
_ProviderAuthResolutionError handler already produces for this endpoint.

Initialize `agent = None` and guard the cleanup, mirroring what the sibling
_run() executor path in this same file already does.

Regression covered by the existing
tests/gateway/test_api_server_runs.py::TestRunsProviderAuthFailure, which was
red on this branch and is green again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is bound

pre_tool_call and subagent_stop were passing agent_id unconditionally, so
every install -- including single-agent ones with no routed profile -- started
receiving an extra `agent_id: None` kwarg. That is a silent contract change for
existing observers and plugins, and it broke two payload-shape assertions in
the tree (tests/hermes_cli/test_plugins.py's first-party observer test and
tests/agent/test_subagent_lifecycle.py's host-aggregation test).

Add the kwarg only when an agent profile is actually bound. Multi-agent
deployments still get the tag they need; single-agent payloads are byte-
identical to before, which is the backward-compatibility promise the rest of
this PR makes for sessions, cron jobs, and state.

Adds tests/agent/test_hook_agent_id_tagging.py pinning both directions for
both hooks (the tagging had no coverage of its own before), and documents the
conditional presence in the multi-agent backward-compatibility section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_resolve_single_delivery_target() built its returned dict in 4 places;
3 of the 4 already threaded the per-agent agent_id field through, but
the origin-platform-matches-with-configured-home-channel branch didn't,
leaving delivery targets resolved that way silently missing agent
routing info. Add it there too, matching every other branch, and
update the two tests that asserted the old (inconsistent) shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prepare the Buzz adapter to run as one of N per-agent connections in a
single gateway process (single-gateway-multi-agent):

* extra.private_key_env NAMES the env var holding this connection's key.
  Resolution: gateway secret scope / process env first, then the agent's
  own .env (extra.env_file). Fail-closed — no fallback to the shared
  BUZZ_PRIVATE_KEY, so a misconfigured agent stays down instead of
  connecting as another workspace member. Legacy resolution is untouched
  when private_key_env is absent.
* extra.agent_id marks the instance as agent-scoped: the agent's own
  relay_url/channels/home_channel/poll_interval/require_mention/
  allowed_users win over process-wide BUZZ_* env vars (one process hosts
  N connections, so a global var can only be a shared default). Legacy
  env-over-extra precedence is byte-identical when agent_id is absent.
* adapter.name carries the agent id (Buzz:chip) so N connections stay
  distinguishable in logs; missing-key errors name the env var, never
  values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…teway

Bridge the AgentProfile registry to identity-bearing platform adapters:
each agent that declares agents.<id>.buzz.nsec_env gets its OWN Buzz
connection, appearing in the workspace as its own member with its own
mention gate and DM inbox.

* gateway/agent_platforms.py: build AgentPlatformBinding per (agent,
  platform) from config_overrides (no changes to agent/profile.py —
  unknown agent keys already land there). nsec_env follows the
  api_key_env idiom (config names an env var, never holds a secret) and
  multiplex_profiles' fail-closed secret handling (agent home .env as
  the second source, no shared-credential fallback).
* GatewayRunner._start_agent_platform_adapters(): connect one adapter
  per binding after primary + multiplexed-profile startup. Same-key
  collisions (with the primary adapter or between agents) are refused
  via salted fingerprints — one key cannot be two members. Fatal errors
  and reconnects are per-slot, recreated from the binding so credentials
  are re-resolved by name; teardown drains the per-agent map.
* Routing: set_routing_context(routes=[], default_agent=<agent_id>) —
  the connection an event arrived on IS the routing decision (the
  message was addressed to that member's identity). The select_agent
  hook still runs. _handle_message already binds the AgentProfile
  ContextVar from source.agent_id, so homes/models/toolsets follow.
* Replies leave as the addressed member: _adapter_for_source prefers the
  agent-owned adapter and fails closed while a declared binding is down
  (never falls back to the shared adapter, which would answer as the
  wrong identity); transport-ref lookup recognizes per-agent adapters.
* docs: multi-agent section in the Buzz platform guide.

Coexists with gateway.multiplex_profiles (per-PROFILE credentials);
eventual unification of the two per-connection credential systems is
deliberately out of scope and flagged in the module docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unit (mock relay, tests/gateway/test_buzz_multi_agent.py, 26 tests):
credential resolution by name incl. fail-closed no-shared-key-fallback;
agent-scoped settings precedence; binding construction (merge, missing/
duplicate nsec_env, fingerprints); identity routing (mention -> owning
agent only, per-agent require_mention, DM p-tag classification per
recipient); outbound resolution (agent adapter wins, declared-but-down
binding fails closed, unbound agents fall back); runner wire-up
(connection identity as routing default, empty routes table).

Integration (tests/integration/buzz/test_live_relay_multi_agent.py):
two real BuzzAdapter connections with freshly minted throwaway keys
against a live relay — channel mention routes to agent A only, p-tagged
DM routes to agent B only (block/buzz#2897 workaround exercised for
real: recipient 'dms list' returns [] on this relay too), and B's reply
is verified on-relay as authored by B's own pubkey. Skip-unless-env
(BUZZ_RELAY_URL + three BUZZ_TEST_*_NSEC vars + buzz/nak binaries) and
marked 'integration', so default CI never collects it. The DM message is
published via nak because 'buzz messages send' does not stamp the
structural recipient p-tag the desktop client adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocess-env check_fn

Plugin check_fn hooks are no-arg and read process-wide env (BUZZ_RELAY_URL
+ a shared BUZZ_PRIVATE_KEY). A deployment configured ONLY through
per-agent bindings (agents.<id>.buzz.nsec_env) has neither, so every
per-agent adapter was refused at the registry gate with 'requirements not
met' — despite the binding having already proven its credential by name
via resolve_binding_secret().

Add an explicit skip_check_fn opt-out to platform_registry.create_adapter
and use it from the two per-agent call sites (initial startup +
reconnect). validate_config still runs — it receives the instance config
and understands private_key_env, so it stays fail-closed for genuinely
unconfigured instances.

Found deploying the Buzz cutover to the live gateway: 11/11 persona
adapters refused at startup with only the misleading CLI install_hint in
the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Membership + published profile are prerequisites per agent key (the
adapter refuses 'users get returned no profile'); display names must not
be word-bounded substrings of each other or the mention gate
double-dispatches; relay identity changes require re-provisioning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oad_gateway_config

The docs (user-guide/messaging/buzz.md) show the multi-agent registry
under gateway.agents, but load_gateway_config() only copied top-level
agents into gw_data — a config written the documented way produced an
empty registry and no per-agent Buzz bindings started.

Add the nested fallback with the repo-wide precedence (top-level wins,
gateway.* is the fallback — same as streaming / reset_triggers /
max_concurrent_sessions), regression-test all three shapes through
load_gateway_config(), and note the dual shape in the docs the same way
configuration.md does for max_concurrent_sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ource inspection

Replace the inspect.getsource() check for skip_check_fn=True with tests
that drive the real _start_agent_platform_adapters /
_run_agent_adapter_reconnect against the real env-gated buzz
PlatformEntry (check_fn refuses under a clean env), mocking only the
connect-with-timeout network seam:

- startup: a registry entry with a buzz binding brings up an adapter
  carrying that agent's identity (agent_id, routing default, named
  credential) despite the refusing check_fn gate
- reconnect: fatal drop frees the slot, and the rebuilt adapter comes
  from the SAME stored binding — same identity, same named credential,
  never the shared BUZZ_PRIVATE_KEY

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jethac
jethac force-pushed the feat/buzz-multi-agent branch from a4bad1f to cc74115 Compare August 17, 2026 03:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/matrix Matrix adapter (E2EE) platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter 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-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/delegate Subagent delegation tool/terminal Terminal execution and process management type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants