Skip to content

feat: multi-agent architecture — named agents with routing, tool policies, and isolated workspaces#896

Closed
teknium1 wants to merge 3 commits into
mainfrom
hermes/hermes-90ec9b1f
Closed

feat: multi-agent architecture — named agents with routing, tool policies, and isolated workspaces#896
teknium1 wants to merge 3 commits into
mainfrom
hermes/hermes-90ec9b1f

Conversation

@teknium1

@teknium1 teknium1 commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

⚠️ DO NOT MERGE — Review/Discussion PR

This PR implements the full multi-agent architecture. It is opened for review and discussion, not immediate merge.

Also includes: feat(subagent) configurable subagent model via config.yaml (cherry-picked from PR #751, first commit)


What This Does

A single Hermes installation can now host multiple named agents, each with its own model, personality, toolset, workspace, and session history. Messages are routed to specific agents via deterministic binding rules.

Quick Example

# config.yaml — Tier 2 (simple)
agents:
  main:
    default: true
    model: anthropic/claude-opus-4.6

  coder:
    model: anthropic/claude-sonnet-4
    personality: "You are a focused coding assistant."
    tools: coding

  assistant:
    model: google/gemini-3-flash-preview
    tools: [web_search, memory, clarify]

bindings:
  - agent: coder
    telegram: "-100123456"
  - agent: assistant
    whatsapp: "*"
hermes chat --agent coder

Architecture (2 new files, 15 modified, 2489 additions)

New: gateway/agent_registry.py (~280 lines)

  • AgentConfig dataclass with 16 fields (model, personality, toolsets, tool_policy, workspace, sandbox, etc.)
  • ToolPolicy with apply(tools) pipeline: profile → also_allow → allow → deny (deny always wins)
  • TOOL_PROFILES: minimal, coding, messaging, full — preset tool bundles
  • AgentRegistry: parses flat YAML dict, resolves personality (config → workspace SOUL.md → global), validates IDs
  • normalize_tool_config(): "coding" → profile, [tools] → allow-list, {profile: x, deny: [...]} → full form

New: gateway/router.py (~130 lines)

  • BindingRouter with 7-tier deterministic routing (most-specific wins):
    1. platform + chat_id (exact channel)
    2. platform + peer (exact DM)
    3. platform + guild_id + chat_type
    4. platform + guild_id / team_id
    5. platform + chat_type
    6. platform only
    7. default agent (fallback)
  • Binding shorthands: {agent: coder, telegram: "-100123"} normalizes automatically

Modified Files

File Change
model_tools.py get_tool_definitions() accepts agent_tool_policy; handle_function_call() extends enabled_tools to gate ALL calls
gateway/session.py build_session_key() takes agent_id + dm_scope, replaces hardcoded "agent:main"
tools/memory_tool.py MemoryStore accepts memory_dir for per-agent memory isolation
agent/prompt_builder.py agent_workspace for per-agent SOUL.md + skills lookup
run_agent.py AIAgent accepts agent_tool_policy + agent_workspace
gateway/run.py Inits registry + router, resolves agent per-message, /agents command
cli.py --agent flag, /agents command, agent config override
hermes_cli/config.py agents/bindings in DEFAULT_CONFIG, config version 7
hermes_cli/commands.py /agents command registered
hermes_cli/main.py --agent CLI arg
tools/delegate_tool.py Configurable max_depth, tool policy inheritance to children

Design Principles

  1. Zero breaking changes — no agents key = single implicit "main" agent, everything works as before
  2. Progressive disclosure — Tier 1: no config needed. Tier 2: 4 lines of YAML. Tier 3: full power
  3. Config-driven — agents defined in YAML, not code
  4. Isolation by default — separate sessions, memory, SOUL.md per agent
  5. Least-privilege — tool restrictions can only narrow, never widen; deny always wins
  6. Sensible defaults — agent without model inherits global; without tools gets full access
  7. Config shorthandstools: coding (profile), tools: [t1, t2] (allow-list), tools: {profile: x, deny: [...]} (full)

Common Recipes

Cheap model for Telegram, powerful for CLI (5 lines):

agents:
  main: {default: true, model: anthropic/claude-opus-4.6}
  telegram-bot: {model: google/gemini-3-flash-preview}
bindings:
  - {agent: telegram-bot, telegram: "*"}

Family-safe WhatsApp bot (no code execution):

agents:
  family:
    model: google/gemini-3-flash-preview
    personality: "You are a friendly family assistant."
    tools: [web_search, web_extract, memory, clarify, todo, image_generate]
bindings:
  - {agent: family, whatsapp: "*"}

Tests

168 new tests across 3 test files:

  • test_agent_registry.py (86 tests): profiles, policies, normalization, registry, validation, inheritance
  • test_router.py (59 tests): normalization, tiers, resolution, AND semantics, edge cases
  • test_multi_agent_integration.py (23 tests): session keys, tool filtering, memory isolation, config

Full suite: 3106 passed, 0 failed.

Future Work (not in this PR)

  • Agent-to-agent messaging, A2A protocol, hermes agents add wizard, per-agent MCP servers, agent handoffs, agent teams

Full Plan

See ~/.hermes/plans/multi-agent-plan.md for the complete 1778-line plan covering research (9 frameworks, 3 protocols, OpenClaw codebase analysis), codebase integration map, UX design, and future roadmap.

Bartok Moltbot and others added 2 commits March 10, 2026 23:45
Allow users to configure a dedicated model for subagents spawned by
delegate_task, so narrowly-scoped subtasks can use a cheaper/faster
model while the parent agent runs on a more powerful one.

Config:
  subagent:
    model: google/gemini-3-flash-preview

Precedence: explicit model arg > config.subagent.model > parent model.

Cherry-picked from PR #751 by Bartok9, rebased onto current main
with conflict resolution and simplified to model-only override
(provider/base_url/api_key stay inherited from parent — covers the
common case of same-provider model swap via OpenRouter).

Closes #609

Co-authored-by: Bartok Moltbot <bartokmoltbot@users.noreply.github.com>
4 new tests verifying:
- Subagent inherits parent model by default
- Config model overrides parent model
- Explicit model arg overrides config
- Graceful fallback when CLI_CONFIG unavailable
…cies, and isolated workspaces

Implements the full multi-agent system for Hermes Agent, allowing a single
installation to host multiple named agents, each with its own model,
personality, toolset, workspace, and session history.

## New Files

- gateway/agent_registry.py: AgentConfig, ToolPolicy, SubagentPolicy,
  AgentRegistry, TOOL_PROFILES (minimal/coding/messaging/full), and
  normalize_tool_config() for shorthand YAML parsing

- gateway/router.py: BindingRouter with 7-tier deterministic routing
  (chat_id > peer > guild+type > guild > platform+type > platform > default)

## Core Changes

- model_tools.py: get_tool_definitions() accepts agent_tool_policy for
  per-agent tool filtering; handle_function_call() extended enabled_tools
  check to gate ALL tool calls (defense-in-depth)

- gateway/session.py: build_session_key() now accepts agent_id and dm_scope
  parameters, replacing hardcoded 'agent:main' with 'agent:{agent_id}'

- tools/memory_tool.py: MemoryStore accepts memory_dir parameter for
  per-agent memory isolation

- agent/prompt_builder.py: build_context_files_prompt() accepts
  agent_workspace for SOUL.md lookup; build_skills_system_prompt()
  accepts agent_skills_dir for per-agent skill overlay

- run_agent.py: AIAgent accepts agent_tool_policy and agent_workspace,
  passes policy through to get_tool_definitions()

- gateway/run.py: Initializes AgentRegistry + BindingRouter, resolves
  agent per-message in _handle_message(), passes config to _run_agent(),
  adds /agents command

- cli.py: --agent flag for selecting named agent profiles, /agents
  slash command, agent config override for model/personality/tools

- hermes_cli/config.py: agents/bindings in DEFAULT_CONFIG, version 7

- tools/delegate_tool.py: Configurable max_depth per-agent, tool policy
  inheritance from parent to child

## Config Format

agents:
  main:
    default: true
  coder:
    model: anthropic/claude-sonnet-4
    personality: 'You are a coding assistant.'
    tools: coding  # or [tool1, tool2] or {profile: x, deny: [...]}

bindings:
  - agent: coder
    telegram: '-100123456'

## Tests

168 new tests across 3 test files (agent_registry, router, integration).
All 3106 tests pass.
@teknium1 teknium1 changed the title feat(subagent): add configurable subagent model via config.yaml feat: multi-agent architecture — named agents with routing, tool policies, and isolated workspaces Mar 11, 2026
@jmporchet

Copy link
Copy Markdown

excited for this

@Mibayy

Mibayy commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Thorough read of the full diff, really clean architecture overall. A few things worth flagging before merge:


1. HERMES_HOME hardcoded at module level, breaks profile support (#1750)

# gateway/agent_registry.py line 231
HERMES_HOME = Path.home() / ".hermes"

This is evaluated once at import time. When hermes -p work-bot sets HERMES_HOME via env before any imports, this constant still resolves to ~/.hermes. The workspace_dir property uses it directly, so all agent workspaces silently land in the default profile regardless of which profile is active.

Fix: match the pattern used elsewhere in the codebase:

HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))

2. Tier 2 (platform + peer) is documented but never actually matches

In router.py, tier 2 is described as "platform + peer (exact DM)", but resolve() doesn't accept a peer kwarg, its signature only exposes platform, chat_id, chat_type, user_id, guild_id, team_id. Since _matches() checks binding keys against kwargs, peer in a binding's match dict will never find a corresponding kwarg and will silently fall through to the default.

Notably, test_router.py itself documents this in a comment (line ~2547):

# peer != user_id in kwargs, so this won't match
assert result == "default"

Either expose peer as a kwarg in resolve() (and map it to user_id or a new field), or drop tier 2 from the docs/PR description to avoid confusion.


3. SubagentPolicy.max_children is defined but never enforced

SubagentPolicy has a max_children: int = 5 field, and tests verify it can be configured. But delegate_task() only reads _max_spawn_depth max_children is never wired up. A user who sets subagents: {max_children: 2} to limit fan-out gets no enforcement.


4. Explicit agents don't inherit global model potentially surprising

From the tests:

def test_explicit_agent_model_not_inherited_from_global(self):
    """Agents defined in agents section do NOT auto-inherit global model.
    (The registry only applies global inheritance for the implicit main agent.)
    """
    coder = registry.get("coder")
    assert coder.model is None  # Not inherited from global

This means if you have model: anthropic/claude-opus-4.6 at top level and define 3 agents without per-agent models, all three agents get model=None and the global default won't apply to them at runtime. Worth documenting explicitly in the config YAML example or adding a global_model_fallback resolution step in AIAgent construction.


5. Minor: /agents command uses emoji 📋

print('\n📋 Configured Agents:\n')

The rest of the CLI uses plain Unicode symbols or plain text. Small thing but inconsistent with the existing terminal output style.

@cyberbobjr

Copy link
Copy Markdown

Hi, i'm really interested by this feature, need some help ?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants