feat: multi-agent architecture — named agents with routing, tool policies, 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.
This commit is contained in:
teknium1 2026-03-11 03:21:12 -07:00
parent 1115e35aae
commit b159002078
17 changed files with 2489 additions and 53 deletions

View file

@ -95,20 +95,22 @@ class MemoryStore:
Tool responses always reflect this live state.
"""
def __init__(self, memory_char_limit: int = 2200, user_char_limit: int = 1375):
def __init__(self, memory_char_limit: int = 2200, user_char_limit: int = 1375, memory_dir: Path = None):
self.memory_entries: List[str] = []
self.user_entries: List[str] = []
self.memory_char_limit = memory_char_limit
self.user_char_limit = user_char_limit
self._memory_dir = memory_dir or MEMORY_DIR
self._memory_dir.mkdir(parents=True, exist_ok=True)
# Frozen snapshot for system prompt -- set once at load_from_disk()
self._system_prompt_snapshot: Dict[str, str] = {"memory": "", "user": ""}
def load_from_disk(self):
"""Load entries from MEMORY.md and USER.md, capture system prompt snapshot."""
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
self._memory_dir.mkdir(parents=True, exist_ok=True)
self.memory_entries = self._read_file(MEMORY_DIR / "MEMORY.md")
self.user_entries = self._read_file(MEMORY_DIR / "USER.md")
self.memory_entries = self._read_file(self._memory_dir / "MEMORY.md")
self.user_entries = self._read_file(self._memory_dir / "USER.md")
# Deduplicate entries (preserves order, keeps first occurrence)
self.memory_entries = list(dict.fromkeys(self.memory_entries))
@ -122,12 +124,12 @@ class MemoryStore:
def save_to_disk(self, target: str):
"""Persist entries to the appropriate file. Called after every mutation."""
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
self._memory_dir.mkdir(parents=True, exist_ok=True)
if target == "memory":
self._write_file(MEMORY_DIR / "MEMORY.md", self.memory_entries)
self._write_file(self._memory_dir / "MEMORY.md", self.memory_entries)
elif target == "user":
self._write_file(MEMORY_DIR / "USER.md", self.user_entries)
self._write_file(self._memory_dir / "USER.md", self.user_entries)
def _entries_for(self, target: str) -> List[str]:
if target == "user":