feat: single gateway, multiple agents — rebased onto current main (supersedes #25660) - #62944
feat: single gateway, multiple agents — rebased onto current main (supersedes #25660)#62944jethac wants to merge 27 commits into
Conversation
Related: #25660 (the original single-gateway multi-agent MVP by @02356abc, still open — this PR is a rebase onto current |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for preserving the original authorship and rebasing this substantial feature.
Problems
gateway/platforms/base.py:2936is not safe for existing adapter construction paths. PR CI shows_attach_agent_id()raises because_default_agent_idis absent, failing active-session merge and Telegram tests.gateway/run.py:3933reads a profile API key fromos.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:181nestsprofiles/below the activeHERMES_HOME;--from-profileis 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_idand add regressions for the failing adapter paths. - Use the existing secret-scope accessor for
api_key_envand test two multiplexed profiles. - Reuse existing profile-root APIs for cloning.
- Reconcile the route-table proposal with main's existing
gateway.multiplex_profileslifecycle before salvaging the parallel profile implementation.
Automated hermes-sweeper review.
| 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" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| return model, runtime_kwargs | ||
|
|
||
| explicit_api_key = ( | ||
| os.getenv(profile.api_key_env) if profile.api_key_env else None |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| # If cloning from an existing profile, copy directory | ||
| if args.from_profile: | ||
| src = get_hermes_home() / "profiles" / args.from_profile |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Thanks @teknium1 for the thorough review — the pointers to the exact seams ( On reconciling the routes table with the |
|
@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: I have the |
|
@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? |
|
Sounds good — I'll pull in my api_server wiring as One heads-up on the base so you know what you're getting: that commit was authored against v0.17.0, and |
|
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
|
|
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 ( 1. Credential scope gap in the routed run (fix — The routed-run path binds the agent profile's home via Fix installs the secret scope alongside the home in both executor threads via a shared 2. The docstring listed "declarative routes → 3. Session
4. Coverage fill ( Audited the PR against its base and filled the gaps that were silent-cross-agent-leak or core-propagation shaped:
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. |
83f939c to
d05ffec
Compare
ad9b4aa to
17637bc
Compare
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>
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:mainhad independently grown the sameagent:<ns>session prefix — collapses byte-identical toagent:main.telegram.pywas replaced by the new adapter — ported the_attach_agent_idinjections.turn_context/turn_finalizer,slash_commands) — re-attachedagent_id.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 ofCRON_DIRby comparing it to a liveget_hermes_home() / "cron". That held whenget_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): fourTELEGRAM_CRON_THREAD_IDdelivery-target assertions (added upstream after this PR's base) still asserted the pre-multi-agent dict shape; they now expect theagent_idfield.Verification (runtime, against current
main)Every module the rebase touches has a passing suite — ~1,600 tests across the blast radius:
resolve_agent_id,AgentProfile/use_profiletools/approvaltools/delegate(+ toolset scope)gateway/deliverygateway/configgateway/slash_accessgateway/agent_cachegateway/runtime_footerhermes_cli/agent(CLI + end-to-endagent add/list/show)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.