Skip to content

feat: single gateway, multiple agents — rebased onto current main (supersedes #25660) - #62944

Open
jethac wants to merge 27 commits into
NousResearch:mainfrom
jethac:feat/single-gateway-multi-agent-rebased
Open

feat: single gateway, multiple agents — rebased onto current main (supersedes #25660)#62944
jethac wants to merge 27 commits into
NousResearch:mainfrom
jethac:feat/single-gateway-multi-agent-rebased

Conversation

@jethac

@jethac jethac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Rebase of #25660 (@02356abc's single-gateway multi-agent MVP) onto current main, per @02356abc's own May 31 suggestion there to open a follow-on PR with a rebased base — which never landed, so here it is.

Credit: the feature and all 7 commits are @02356abc's — authorship is preserved unchanged. My contribution is the rebase itself plus two commits on top (a cron path-resolution fix + a test update the rebase surfaced).

What's here

The 7 original commits rebased onto current main (~6.5k commits of drift), resolved onto main's newer structure rather than reverting it:

  • main had independently grown the same agent:<ns> session prefix — collapses byte-identical to agent:main.
  • telegram.py was replaced by the new adapter — ported the _attach_agent_id injections.
  • Several hooks moved homes (turn_context/turn_finalizer, slash_commands) — re-attached agent_id.
  • The PR's registry / per-profile-path changes threaded through main's cron refactor.

Plus two commits of mine on top:

  • fix(cron): _get_cron_dir() detected a test monkeypatch of CRON_DIR by comparing it to a live get_hermes_home() / "cron". That held when get_hermes_home() was static, but this PR makes it profile-/HERMES_HOME-dynamic — so any active-profile or env change was mistaken for a monkeypatch and returned the stale import-time path. Now compares to a frozen import-time default (_CRON_DIR_IMPORT_DEFAULT). Fixes an order-dependent cron-suite failure.
  • test(cron): four TELEGRAM_CRON_THREAD_ID delivery-target assertions (added upstream after this PR's base) still asserted the pre-multi-agent dict shape; they now expect the agent_id field.

Verification (runtime, against current main)

Every module the rebase touches has a passing suite — ~1,600 tests across the blast radius:

Area Result
Multi-agent core — session-key namespace + precedence, resolve_agent_id, AgentProfile/use_profile 184 ✓
Cron (full suite) 679 ✓
tools/approval 298 ✓
tools/delegate (+ toolset scope) 165 ✓
gateway/delivery 28 ✓
gateway/config 99 ✓
gateway/slash_access 21 ✓
gateway/agent_cache 78 ✓
gateway/runtime_footer 25 ✓
hermes_cli/agent (CLI + end-to-end agent add/list/show) 24 ✓
model_tools (hand-merged hooks)

Not run: integration tests requiring network / API keys / live session infra — those are environmental and fail identically on main.

Tracking: #7517, #9514.

File-location note (2026-07-30): since upstream's 21c7ae856 split SessionDB into mixins, this PR's schema changes live in their new homes — the sessions.agent_id column in hermes_state_common.py (SCHEMA_SQL) and the idx_sessions_agent index in hermes_state_schema.py; only the create_session INSERT remains in hermes_state.py.

@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/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/cron Cron scheduler and job management platform/telegram Telegram bot adapter platform/discord Discord bot adapter platform/slack Slack app adapter platform/matrix Matrix adapter (E2EE) platform/feishu Feishu / Lark adapter platform/wecom WeCom / WeChat Work adapter P2 Medium — degraded but workaround exists 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-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 12, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #25660 (the original single-gateway multi-agent MVP by @02356abc, still open — this PR is a rebase onto current main that supersedes it, with authorship preserved) and #34741 (an earlier closed rebase attempt). Salvage/rebase, not a duplicate. Note: the failing check-attribution CI job is the attribution workflow (expected for a rebase preserving upstream commits), not a Nix failure. A maintainer should pick between this rebased branch and #25660.

@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 preserving the original authorship and rebasing this substantial feature.

Problems

  • gateway/platforms/base.py:2936 is not safe for existing adapter construction paths. PR CI shows _attach_agent_id() raises because _default_agent_id is absent, failing active-session merge and Telegram tests.
  • gateway/run.py:3933 reads a profile API key from os.getenv. Current multiplexing deliberately uses fail-closed scoped secret reads (agent/secret_scope.py:123-159); this bypass risks selecting another profile's process-global credential.
  • hermes_cli/agent.py:181 nests profiles/ below the active HERMES_HOME; --from-profile is therefore wrong when invoked from a named profile. Existing profile enumeration is root-anchored (hermes_cli/profiles.py:949-987).

Suggested changes

  • Guard/fallback routing state in _attach_agent_id and add regressions for the failing adapter paths.
  • Use the existing secret-scope accessor for api_key_env and test two multiplexed profiles.
  • Reuse existing profile-root APIs for cloning.
  • Reconcile the route-table proposal with main's existing gateway.multiplex_profiles lifecycle before salvaging the parallel profile implementation.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py
except Exception as exc:
logger.debug("[%s] select_agent hook failed: %s", self.name, exc)

agent_id = hook_pick or route_match or self._default_agent_id or "main"

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.

Blocking: this assumes every adapter instance ran BasePlatformAdapter.__init__. CI shows _DummyAdapter and TelegramAdapter paths reach here without _default_agent_id, raising AttributeError and failing existing batching/auth tests. Use a safe default/guard and add a regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d7d2d4c_default_agent_id now has a class-level default ("main"), so adapter instances and test doubles built via object.__new__ (bypassing __init__) resolve to the legacy main agent instead of raising in _attach_agent_id. The active-session-merge and Telegram suites that were failing on this pass on the current head.

Comment thread gateway/run.py Outdated
return model, runtime_kwargs

explicit_api_key = (
os.getenv(profile.api_key_env) if profile.api_key_env else None

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.

Blocking: this bypasses the multiplexing secret scope with a direct process-environment read. Current agent.secret_scope.get_secret() intentionally makes scoped profile secrets authoritative and prevents cross-profile fallback; use that accessor (or the scope-aware runtime-provider helper) here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d30cdaf — the pinned credential now resolves through agent.secret_scope.get_secret(): a scoped turn reads its own profile's secrets, an unscoped read under gateway.multiplex_profiles raises UnscopedSecretError (fail closed) instead of touching the process environment, and single-profile installs keep the legacy os.getenv behavior. Regression tests in tests/gateway/test_profile_overrides.py cover two multiplexed profiles resolving distinct keys from their own scopes (with a decoy value planted in os.environ) plus the unscoped fail-closed path.

Comment thread hermes_cli/agent.py Outdated

# If cloning from an existing profile, copy directory
if args.from_profile:
src = get_hermes_home() / "profiles" / args.from_profile

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.

Use the existing root-anchored profile helpers here. With a named active profile, get_hermes_home() is already <root>/profiles/<active>, so this resolves a nonexistent nested profiles/ directory and breaks --from-profile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in aee7f0b — cloning now delegates to the root-anchored hermes_cli.profiles.create_profile(clone_from=…, clone_all=True, no_alias=True), which also picks up name validation, default-profile source handling, runtime-file stripping, and .env permission tightening. Added a regression test that runs hermes agent add --from-profile from inside a named profile (HERMES_HOME=<root>/profiles/<name>) and asserts the clone lands under the root profiles/ dir rather than nesting.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 12, 2026
@jethac

jethac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @teknium1 for the thorough review — the pointers to the exact seams (secret_scope, the root-anchored profile APIs) made these quick to fix. All three problems are addressed on the branch (_attach_agent_id in d7d2d4c, api_key_envget_secret() in d30cdaf, root-anchored --from-profile cloning in aee7f0b), each with regression tests — I've replied inline on each of your three threads with the specifics. CI is green on the new head.

On reconciling the routes table with the gateway.multiplex_profiles lifecycle: agreed this is the real design question. The intent in this rebase is that the AgentProfile registry layers on top of the multiplex lifecycle rather than replacing it — the get_secret() fix closes the one spot where the two disagreed on credential resolution. A fuller unification (expressing multiplexed profiles as registry entries so profiles_to_serve() and config.agents share one source of truth) feels like it deserves its own PR; happy to take that as a follow-up, or fold it in here if you consider it blocking.

@davidgut1982

Copy link
Copy Markdown
Contributor

@jethac — ceding the rebased base to you; thanks for picking it up and getting it green. It's in better shape than my May branch ever was, and the LINE/delivery background makes you the right person to carry the single-adapter routing.

One gap worth flagging: gateway/platforms/api_server.py still isn't agent-aware on main. It reads the active profile name only to label the advertised model (_resolve_model_name) — there's no _attach_agent_id stamping and no profile-scoped routing. Your rebase ports _attach_agent_id into the messaging adapters (telegram/discord/slack/matrix/feishu/wecom), but — mirroring the original MVP — it looks like api_server stays uncovered, so an inbound API-server request won't carry an agent_id.

I have the _attach_agent_id wiring for api_server (the follow-on I mentioned back in May). Happy to send it as a small standalone PR on top once yours lands — crediting your base, no need to widen your diff. Ping me if you'd rather fold it in.

@jethac

jethac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

@davidgut1982 - thanks for that! I'd love to take a look at what you've got, and to fold your work in as your original authored commits. How would you like to do it - maybe rebase your work on this, then send me a link to your branch?

@davidgut1982

Copy link
Copy Markdown
Contributor

Sounds good — I'll pull in my api_server wiring as 643bbbf5a (feat(gateway): wire api_server adapter to multi-agent routing): the _attach_agent_id path for the API-server adapter plus a test_api_server_routing.py suite.

One heads-up on the base so you know what you're getting: that commit was authored against v0.17.0, and api_server.py has since been through the session-context refactor on main, so it won't cherry-pick clean. I'll port it onto your branch — re-fit the wiring to main's current _attach_agent_id / session-context shape, carry the test over, and get the api_server suite green — and keep it as my authored commit so you can fold it in with authorship preserved. I'll push to my fork and send you the branch link.

@davidgut1982

Copy link
Copy Markdown
Contributor

Branch's up: https://github.com/davidgut1982/hermes-agent/tree/feat/api-server-agent-routing

One commit, authored by me, ported onto your rebased base — re-fit to the current api_server.py (post session-context refactor), not the v0.17.0 shape it was written against. It reuses your machinery rather than reintroducing old shapes: reads X-Hermes-Chat-Id / X-Hermes-User-Id / X-Hermes-Thread-Id, resolves the agent via the shared _attach_agent_id routes table + select_agent hook, and runs both the sync _run_agent path and the async _handle_runs (executor-thread) path under the resolved profile via use_profile. Unmapped requests fall back to main with no wrapper, so single-agent installs are unaffected.

tests/gateway/test_api_server_routing.py → 20/20 green, ruff clean, no new regressions in the existing api_server suites. Pull it into the PR however suits you — authorship's set so credit carries.

@jethac

jethac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Nice work on the api_server routing wiring, David. I did an architectural pass over it plus a coverage audit of the wider multi-agent PR, and pushed your commit (b43a05a33) plus three of mine on top. Nothing here blocks — one real fix, one doc nit, and a coverage fill.

1. Credential scope gap in the routed run (fix — e06904247)

The routed-run path binds the agent profile's home via use_profile, but not its fail-closed credential scope. Under gateway.multiplex_profiles, provider/LLM keys are profile-scoped (not global env), so credential_pool → get_secret in the routed run would fail closed with UnscopedSecretError (or read another profile's process-global value) instead of resolving the routed agent's key.

Fix installs the secret scope alongside the home in both executor threads via a shared _use_profile_and_secret_scope helper, mirroring the base adapter's _profile_runtime_scope. None profile stays a pure home no-op. Regression tests include one that drives _run_agent itself and asserts the scope is live at agent-creation time — mutation-verified to fail if the fix is reverted.

2. _attach_agent_id docstring precedence was backwards (doc-only — 83f939ca2)

The docstring listed "declarative routes → select_agent hook → default," which reads as routes-first fallback. The code is hook_pick or route_match or default or "main" — the hook is always consulted, is handed route_match, and a truthy hook result overrides the route. That's clearly the intent (it matches set_routing_context's own doc: the hook "overriding the route result"), so I corrected the stale docstring to match. Flag me if the prose was right and the precedence is what's actually off — I read it as intended.

3. Session agent_id is first-writer-wins (flag only — no change)

SessionDB.create_session uses COALESCE(sessions.agent_id, excluded.agent_id) on a NOT NULL DEFAULT 'main' column. Net effect: a session first created without an agent_id is permanently main — a later routed create_session(..., agent_id="research") on the same id won't upgrade it. So agent routing has to be decided at session creation, not backfilled. Might be exactly what you want; I didn't touch it, just pinned the behavior in a test so it's explicit.

4. Coverage fill (4a1402ec6 — tests only, 69 tests)

Audited the PR against its base and filled the gaps that were silent-cross-agent-leak or core-propagation shaped:

  • deliveryDeliveryRouter.deliver runs each target inside its routed profile; unknown/absent agent_idnullcontext fallback.
  • base adapter_attach_agent_id resolution order, idempotency, hook override, fail-open on resolver/hook/replace errors (the routing linchpin had no direct tests; select_agent had none anywhere).
  • cronload_all_jobs/get_all_due_jobs per-profile stamping (main default; one bad profile doesn't starve siblings); _resolve_single_delivery_target propagates job.agent_id; cron-dir stays profile-dynamic.
  • config/stateGatewayConfig.from_dict routes/agents/default parsing + malformed fallbacks; create_session persistence + legacy-column reconcile.
  • session/hooksSessionEntry roundtrip + legacy default; build_session_key agent_id-over-profile precedence; post_tool_call/transform_tool_result carry the active profile's agent_id.

Full touched-file suite is green (965 passed), ruff clean. Happy to squash the doc commit into yours, or split anything out if you'd rather.

@jethac
jethac force-pushed the feat/single-gateway-multi-agent-rebased branch from 83f939c to d05ffec Compare July 12, 2026 15:45
@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Aug 13, 2026
@jethac
jethac force-pushed the feat/single-gateway-multi-agent-rebased branch from ad9b4aa to 17637bc Compare August 15, 2026 00:29
02356abc and others added 27 commits August 17, 2026 11:48
Introduce AgentProfile dataclass and a ContextVar (_current_agent_profile)
that lets path getters (get_hermes_home, get_skills_dir, get_memory_dir)
resolve to the active agent's home directory under asyncio.

- agent/profile.py: AgentProfile, use_profile() context manager,
  load_agent_registry() from GatewayConfig
- hermes_constants.py: get_hermes_home() reads ContextVar before env fallback
- tests/agent/test_profile_contextvar.py: ContextVar isolation under
  asyncio.gather, nested contexts, registry loading

Single-agent installs see zero change — no profile bound means fallback
to HERMES_HOME env var as before.
Add agent_id field to SessionSource and SessionEntry, prefix session keys
with agent:<id>: in build_session_key. Default "main" preserves every
historical key string for single-agent installs.

- gateway/session.py: SessionSource.agent_id, SessionEntry.agent_id,
  build_session_key prefixing
- hermes_state.py: sessions table migration (agent_id TEXT DEFAULT 'main'),
  new idx_sessions_agent index
- tests/gateway/test_session.py: build_session_key prefixing for all
  chat_type × agent_id combinations
- tests/*/test_session_boundary_hooks.py: hook payload agent_id kwarg

[rebase note 2026-07-30: upstream 21c7ae8 split SessionDB into mixins; the
sessions-table agent_id column now lands in hermes_state_common.py (SCHEMA_SQL)
and the idx_sessions_agent creation in hermes_state_schema.py. Semantics
unchanged.]
… hook

Add declarative routing (routes: match → agent) and a select_agent plugin
hook. _attach_agent_id injects the resolved agent_id into event.source
before build_session_key. Seven platform adapters get pre-injection for
batching paths; the rest inherit it from base.py.

- gateway/agent_routing.py: resolve_agent_id(), _route_matches()
- gateway/config.py: agents, routes, default_agent schema
- gateway/platforms/base.py: _attach_agent_id(), set_routing_context()
- gateway/platforms/{telegram,discord,slack,matrix,feishu,wecom,yuanbao}.py:
  pre-batch injection
- hermes_cli/plugins.py: select_agent hook registration
- tests/gateway/test_agent_routing.py: declared-order matching, hook chain,
  default fallback, profile isolation
…s agent_id to hooks

GatewayRunner loads the agent registry at init and wraps every inbound
message in use_profile(). AIAgent accepts an optional profile= kwarg.
All invoke_hook call sites gain agent_id= kwarg. _handle_message is
split into _handle_message (ContextVar plumbing) + _handle_message_inner
(legacy logic) so tests that grep the source body continue to work.

- gateway/run.py: registry loading, use_profile() wrapping, hook kwargs
- run_agent.py: AIAgent(profile=), profile-aware model/toolset resolution
- model_tools.py, tools/{approval,terminal,delegate}.py: hook agent_id
- cli.py, tui_gateway/server.py: session boundary hook agent_id
- tests/gateway/test_profile_overrides.py: per-agent model/toolset overrides
- tests/test_model_tools.py: hook payload verification
- tests/gateway/test_{update,title,reasoning}_command.py: adapt to
  _handle_message split
…veries

Cron tick and delivery routing now bind the correct profile before
execution. jobs.py does NOT persist agent_id in JSON — the directory
is the identity. Delivery uses nullcontext() for the unrouted case.

- cron/jobs.py: in-memory agent_id stamping at read time, directory-based
  identity (no JSON field)
- cron/scheduler.py: use_profile() wrapper in tick path
- gateway/delivery.py: use_profile() wrapper per delivery target
- tests/cron/test_scheduler.py: agent_id propagation in delivery targets
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history 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 P2 Medium — degraded but workaround exists 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