feat(gateway): per-agent Buzz identities — N agents, N workspace members, one gateway process - #71686
Open
jethac wants to merge 34 commits into
Open
feat(gateway): per-agent Buzz identities — N agents, N workspace members, one gateway process#71686jethac wants to merge 34 commits into
jethac wants to merge 34 commits into
Conversation
This was referenced Jul 26, 2026
This was referenced Jul 26, 2026
jethac
force-pushed
the
feat/buzz-multi-agent
branch
2 times, most recently
from
July 29, 2026 17:39
45dc746 to
b0374e4
Compare
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
force-pushed
the
feat/buzz-multi-agent
branch
from
August 17, 2026 03:15
a4bad1f to
cc74115
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thefirst 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:
(per-agent mention gate, per-agent
require_mention);classification — the fix(tools): treat Slack channel IDs as explicit send_message targets #2897 workaround, applied per identity);
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
Design decisions, and why:
agents.<id>.buzz.nsec_env(a name, never a value). This follows theexisting feat: single gateway, multiple agents — rebased onto current main (supersedes #25660) #62944 idiom (
AgentProfile.api_key_env) and thegateway.multiplex_profilessecret discipline: config names credentials,.envholds them. Resolution order is gateway secret scope / process envfirst, then the agent's own
<home_dir>/.env(the agent-registry analogueof a profile home). There is deliberately no fallback to the shared
BUZZ_PRIVATE_KEY— a misconfigured agent stays offline rather thanconnecting as another member.
load_agent_registryalreadyforwards unknown agent keys into
AgentProfile.config_overrides, so thebuzz:block needs no changes toagent/profile.py.set_routing_context(routes=[], default_agent=<agent_id>): a message thatarrived 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 weresent to). The
select_agentplugin hook still runs. Everything downstreamis 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.
scheme as the multiplex same-token guard): one key cannot be two members,
and two pollers on one key would race per message.
agent's block wins over process-wide
BUZZ_*env vars (N connections inone process: a global var can only be a shared default). Legacy
single-connection precedence (env > extra) is byte-identical when
agent_idis 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 listreturns[]for recipients onsome relays; DMs are recovered from
channels listand latched by thestructural 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 listreturned[]there too, and thep-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 sendinto a DMconversation carry only the
htag — the structural recipient p-tag thatthe 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
DMwith empty description AND sender ≠self" as a weaker classification signal, or ask block/buzz to stamp the
p-tag relay-side.
Relationship to
multiplex_profilesand #70326Upstream now has three per-something systems that touch connections and
credentials:
gateway.multiplex_profiles.env+ secret scopesapi_key_env(LLM) — now also<platform>.nsec_env(this PR)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 toolpolicy (#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.py—private_key_envresolution(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 adaptersgateway/authz_mixin.py— outbound resolution: agent-owned adapter wins,declared-but-down binding fails closed
gateway/platform_registry.py—create_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 belowFound in production: the plugin
check_fngate vs per-agent-only configsThis 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_fnhooks are no-arg andread process-wide env (
BUZZ_RELAY_URL+ a resolvable shared key), so agateway configured only through
agents.<id>.buzz.nsec_envhad everyper-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=Truetoplatform_registry.create_adapter— justifiedbecause
resolve_binding_secret()has already proven the credential byname before an adapter is requested, and
validate_config(which receivesthe instance config and understands
private_key_env) still runs, sogenuinely 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):
(Re-run after the rebase onto main with #73610's bundled adapter.)
backward-compatible by construction).
concurrency, back-compat) + multiplex-profile suites: passing.
fail-closed case), binding construction (missing/duplicate
nsec_env),identity routing (mention → owning agent only; per-agent
require_mention; DM p-tag per recipient), outbound fail-closedresolution, runner wire-up, and the registry
skip_check_fngate(bypass creates the adapter;
validate_configstill fails closed).Live relay (self-hosted Buzz relay, three freshly minted throwaway member
keys; skip-unless-env —
BUZZ_RELAY_URL+ threeBUZZ_TEST_*_NSECvars +buzz/nakbinaries — and markedintegration, so default CI never runsit):
The test runs two real
BuzzAdapterconnections in one process andverifies:
@AgentA <nonce>in a shared channel dispatches on A'sconnection only, stamped
agent_id="agent_a", mention stripped; a p-taggedDM dispatches on B's connection only (
chat_type="dm",agent_id="agent_b") with recipientdms listempty 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.