mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
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:
parent
1115e35aae
commit
b159002078
17 changed files with 2489 additions and 53 deletions
504
gateway/agent_registry.py
Normal file
504
gateway/agent_registry.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
"""
|
||||
Agent registry for multi-agent support.
|
||||
|
||||
Manages agent configurations, tool policies, and workspace resolution.
|
||||
Each agent has its own identity, model settings, tool access, and workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HERMES_HOME = Path.home() / ".hermes"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool profiles -- predefined sets of allowed tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_PROFILES: Dict[str, Dict[str, Any]] = {
|
||||
"minimal": {
|
||||
"allow": [
|
||||
"clarify",
|
||||
"memory",
|
||||
"todo",
|
||||
"session_search",
|
||||
],
|
||||
},
|
||||
"coding": {
|
||||
"allow": [
|
||||
"terminal",
|
||||
"process",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"patch",
|
||||
"search_files",
|
||||
"web_search",
|
||||
"web_extract",
|
||||
"memory",
|
||||
"todo",
|
||||
"clarify",
|
||||
"session_search",
|
||||
"delegate_task",
|
||||
"execute_code",
|
||||
"vision_analyze",
|
||||
],
|
||||
},
|
||||
"messaging": {
|
||||
"allow": [
|
||||
"web_search",
|
||||
"web_extract",
|
||||
"memory",
|
||||
"todo",
|
||||
"clarify",
|
||||
"session_search",
|
||||
"send_message",
|
||||
"text_to_speech",
|
||||
"image_generate",
|
||||
],
|
||||
},
|
||||
"full": {}, # No restrictions
|
||||
}
|
||||
|
||||
# Valid agent ID pattern: starts with lowercase letter/digit, rest can include _ and -
|
||||
_AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolPolicy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""
|
||||
Declarative tool access policy for an agent.
|
||||
|
||||
Resolution pipeline (applied in order):
|
||||
1. Start with the profile's allow-list (or all tools if no profile / 'full').
|
||||
2. Add any names from ``also_allow``.
|
||||
3. If an explicit ``allow`` list is set, intersect with it.
|
||||
4. Remove any names from ``deny`` (deny always wins).
|
||||
"""
|
||||
|
||||
profile: Optional[str] = None
|
||||
allow: Optional[List[str]] = None
|
||||
also_allow: Optional[List[str]] = None
|
||||
deny: Optional[List[str]] = None
|
||||
|
||||
def apply(self, tools: Set[str]) -> Set[str]:
|
||||
"""
|
||||
Filter a set of tool names according to this policy.
|
||||
|
||||
The pipeline is: profile -> also_allow -> allow -> deny.
|
||||
Deny always wins — denied tools are removed regardless of other rules.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tools:
|
||||
The full set of available tool names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Set[str]
|
||||
The subset of tools this agent is permitted to use.
|
||||
"""
|
||||
# Step 1: Start from profile
|
||||
if self.profile and self.profile in TOOL_PROFILES:
|
||||
profile_def = TOOL_PROFILES[self.profile]
|
||||
if "allow" in profile_def:
|
||||
result = tools & set(profile_def["allow"])
|
||||
else:
|
||||
# Profile like 'full' with no allow list => all tools
|
||||
result = set(tools)
|
||||
else:
|
||||
# No profile => start with all tools
|
||||
result = set(tools)
|
||||
|
||||
# Step 2: Additive extras from also_allow
|
||||
if self.also_allow:
|
||||
result |= tools & set(self.also_allow)
|
||||
|
||||
# Step 3: Explicit allow list narrows the result
|
||||
if self.allow is not None:
|
||||
result &= set(self.allow)
|
||||
|
||||
# Step 4: Deny always wins
|
||||
if self.deny:
|
||||
result -= set(self.deny)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentPolicy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SubagentPolicy:
|
||||
"""Controls how an agent may spawn sub-agents."""
|
||||
|
||||
max_depth: int = 2
|
||||
max_children: int = 5
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""
|
||||
Full configuration for a single agent persona.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
id:
|
||||
Unique identifier (lowercase, alphanumeric + hyphens/underscores).
|
||||
description:
|
||||
Human-readable description of this agent's purpose.
|
||||
default:
|
||||
Whether this is the default agent (exactly one must be default).
|
||||
model:
|
||||
LLM model identifier. ``None`` inherits the global default.
|
||||
provider:
|
||||
LLM provider name (e.g. ``'anthropic'``, ``'openai'``).
|
||||
personality:
|
||||
Inline personality/system prompt text, or path to a file.
|
||||
workspace:
|
||||
Custom workspace directory override. ``None`` uses the default.
|
||||
toolsets:
|
||||
List of toolset names to load (overrides platform default).
|
||||
tool_policy:
|
||||
Declarative tool access restrictions.
|
||||
reasoning:
|
||||
Provider-specific reasoning/thinking configuration dict.
|
||||
max_turns:
|
||||
Maximum agentic loop iterations per request.
|
||||
sandbox:
|
||||
Sandbox/isolation configuration dict.
|
||||
fallback_model:
|
||||
Fallback model configuration dict (used on primary failure).
|
||||
memory_enabled:
|
||||
Whether long-term memory is active for this agent.
|
||||
subagents:
|
||||
Sub-agent spawning policy.
|
||||
dm_scope:
|
||||
Which agent handles DMs on messaging platforms (``'main'`` by default).
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str = ""
|
||||
default: bool = False
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
personality: Optional[str] = None
|
||||
workspace: Optional[str] = None
|
||||
toolsets: Optional[List[str]] = None
|
||||
tool_policy: Optional[ToolPolicy] = None
|
||||
reasoning: Optional[Dict[str, Any]] = None
|
||||
max_turns: Optional[int] = None
|
||||
sandbox: Optional[Dict[str, Any]] = None
|
||||
fallback_model: Optional[Dict[str, Any]] = None
|
||||
memory_enabled: bool = True
|
||||
subagents: SubagentPolicy = field(default_factory=SubagentPolicy)
|
||||
dm_scope: str = "main"
|
||||
|
||||
# -- derived paths -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def workspace_dir(self) -> Path:
|
||||
"""Agent-specific workspace directory."""
|
||||
if self.workspace:
|
||||
return Path(self.workspace).expanduser()
|
||||
return HERMES_HOME / "agents" / self.id
|
||||
|
||||
@property
|
||||
def sessions_dir(self) -> Path:
|
||||
"""Directory for this agent's session data."""
|
||||
return self.workspace_dir / "sessions"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_tool_config(raw: Any) -> Optional[ToolPolicy]:
|
||||
"""
|
||||
Coerce various shorthand forms into a ``ToolPolicy``.
|
||||
|
||||
Accepted inputs::
|
||||
|
||||
None -> None
|
||||
"coding" -> ToolPolicy(profile="coding")
|
||||
["read_file", …] -> ToolPolicy(allow=[…])
|
||||
{profile: …, …} -> ToolPolicy(**dict)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw:
|
||||
Raw tool policy value from configuration.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[ToolPolicy]
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
return ToolPolicy(profile=raw)
|
||||
if isinstance(raw, list):
|
||||
return ToolPolicy(allow=raw)
|
||||
if isinstance(raw, dict):
|
||||
return ToolPolicy(
|
||||
profile=raw.get("profile"),
|
||||
allow=raw.get("allow"),
|
||||
also_allow=raw.get("also_allow"),
|
||||
deny=raw.get("deny"),
|
||||
)
|
||||
raise TypeError(f"Invalid tool_policy value: {raw!r}")
|
||||
|
||||
|
||||
def _validate_agent_id(agent_id: str) -> None:
|
||||
"""Raise ``ValueError`` if *agent_id* is not a valid identifier."""
|
||||
if not _AGENT_ID_RE.match(agent_id):
|
||||
raise ValueError(
|
||||
f"Invalid agent id {agent_id!r}. Must match "
|
||||
f"[a-z0-9][a-z0-9_-]{{0,63}}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentRegistry:
|
||||
"""
|
||||
Registry of configured agent personas.
|
||||
|
||||
Parses the ``agents`` section of the top-level config dict and exposes
|
||||
lookup / resolution helpers used by the runtime.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, global_config: dict = None) -> None:
|
||||
self._agents: Dict[str, AgentConfig] = {}
|
||||
self._default_id: str = "main"
|
||||
self._parse_agents(config, global_config)
|
||||
|
||||
# -- parsing -------------------------------------------------------------
|
||||
|
||||
def _parse_agents(self, config: dict, global_config: dict = None) -> None:
|
||||
"""
|
||||
Parse ``config['agents']`` into ``AgentConfig`` instances.
|
||||
|
||||
If the config has no ``agents`` key an implicit *main* agent is
|
||||
created from ``global_config`` so the system always has at least
|
||||
one agent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config:
|
||||
Config dict that may contain an ``agents`` key with a flat dict
|
||||
of agent definitions keyed by name.
|
||||
global_config:
|
||||
Top-level global config dict used to populate the implicit
|
||||
*main* agent when no ``agents`` key is present.
|
||||
"""
|
||||
agents_raw: Optional[Dict[str, Any]] = config.get("agents")
|
||||
|
||||
if not agents_raw:
|
||||
# Implicit single-agent setup — derive from global_config
|
||||
gc = global_config or {}
|
||||
main = AgentConfig(
|
||||
id="main",
|
||||
default=True,
|
||||
model=gc.get("model"),
|
||||
provider=gc.get("provider"),
|
||||
personality=gc.get("personality"),
|
||||
tool_policy=normalize_tool_config(gc.get("tools")),
|
||||
reasoning=gc.get("reasoning"),
|
||||
max_turns=gc.get("max_turns"),
|
||||
memory_enabled=gc.get("memory_enabled", True),
|
||||
)
|
||||
self._agents = {"main": main}
|
||||
self._default_id = "main"
|
||||
return
|
||||
|
||||
agents: Dict[str, AgentConfig] = {}
|
||||
seen_ids: Set[str] = set()
|
||||
default_id: Optional[str] = None
|
||||
first_id: Optional[str] = None
|
||||
|
||||
for name, agent_data in agents_raw.items():
|
||||
if agent_data is None:
|
||||
agent_data = {}
|
||||
|
||||
agent_id = agent_data.get("id", name)
|
||||
_validate_agent_id(agent_id)
|
||||
|
||||
if agent_id in seen_ids:
|
||||
raise ValueError(f"Duplicate agent id: {agent_id!r}")
|
||||
seen_ids.add(agent_id)
|
||||
|
||||
if first_id is None:
|
||||
first_id = agent_id
|
||||
|
||||
# Normalize the tools / tool_policy field
|
||||
tool_policy = normalize_tool_config(
|
||||
agent_data.get("tools", agent_data.get("tool_policy"))
|
||||
)
|
||||
|
||||
subagent_raw = agent_data.get("subagents")
|
||||
if isinstance(subagent_raw, dict):
|
||||
subagent_policy = SubagentPolicy(**subagent_raw)
|
||||
else:
|
||||
subagent_policy = SubagentPolicy()
|
||||
|
||||
is_default = agent_data.get("default", False)
|
||||
|
||||
agent_cfg = AgentConfig(
|
||||
id=agent_id,
|
||||
description=agent_data.get("description", ""),
|
||||
default=is_default,
|
||||
model=agent_data.get("model"),
|
||||
provider=agent_data.get("provider"),
|
||||
personality=agent_data.get("personality"),
|
||||
workspace=agent_data.get("workspace"),
|
||||
toolsets=agent_data.get("toolsets"),
|
||||
tool_policy=tool_policy,
|
||||
reasoning=agent_data.get("reasoning"),
|
||||
max_turns=agent_data.get("max_turns"),
|
||||
sandbox=agent_data.get("sandbox"),
|
||||
fallback_model=agent_data.get("fallback_model"),
|
||||
memory_enabled=agent_data.get("memory_enabled", True),
|
||||
subagents=subagent_policy,
|
||||
dm_scope=agent_data.get("dm_scope", "main"),
|
||||
)
|
||||
|
||||
if is_default:
|
||||
if default_id is not None:
|
||||
raise ValueError(
|
||||
f"Multiple default agents: {default_id!r} and {agent_id!r}"
|
||||
)
|
||||
default_id = agent_id
|
||||
|
||||
agents[agent_id] = agent_cfg
|
||||
|
||||
# If nobody was explicitly marked default, the first agent wins
|
||||
if default_id is None and first_id is not None:
|
||||
default_id = first_id
|
||||
agents[first_id].default = True
|
||||
logger.debug(
|
||||
"No explicit default agent; using first: %s", first_id
|
||||
)
|
||||
|
||||
self._agents = agents
|
||||
self._default_id = default_id or "main"
|
||||
|
||||
# -- public API ----------------------------------------------------------
|
||||
|
||||
def get(self, agent_id: str) -> AgentConfig:
|
||||
"""
|
||||
Return the config for *agent_id*, falling back to the default agent.
|
||||
"""
|
||||
return self._agents.get(agent_id, self.get_default())
|
||||
|
||||
def get_default(self) -> AgentConfig:
|
||||
"""Return the default agent configuration."""
|
||||
return self._agents[self._default_id]
|
||||
|
||||
def list_agents(self) -> List[AgentConfig]:
|
||||
"""Return all registered agent configurations."""
|
||||
return list(self._agents.values())
|
||||
|
||||
# -- resolution helpers --------------------------------------------------
|
||||
|
||||
def resolve_personality(self, agent: AgentConfig) -> Optional[str]:
|
||||
"""
|
||||
Resolve the personality/system-prompt text for *agent*.
|
||||
|
||||
Resolution order:
|
||||
1. ``agent.personality`` field (inline text or file path).
|
||||
2. ``SOUL.md`` in the agent's workspace directory.
|
||||
3. Global ``~/.hermes/SOUL.md`` (only for the *main* agent).
|
||||
4. ``None``.
|
||||
"""
|
||||
# 1. Explicit personality in config
|
||||
if agent.personality:
|
||||
personality_path = Path(agent.personality).expanduser()
|
||||
if personality_path.is_file():
|
||||
try:
|
||||
return personality_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not read personality file: %s", personality_path
|
||||
)
|
||||
# Treat as inline text
|
||||
return agent.personality
|
||||
|
||||
# 2. Workspace SOUL.md
|
||||
workspace_soul = agent.workspace_dir / "SOUL.md"
|
||||
if workspace_soul.is_file():
|
||||
try:
|
||||
return workspace_soul.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not read workspace SOUL.md: %s", workspace_soul
|
||||
)
|
||||
|
||||
# 3. Global SOUL.md (main agent only)
|
||||
if agent.id == "main":
|
||||
global_soul = HERMES_HOME / "SOUL.md"
|
||||
if global_soul.is_file():
|
||||
try:
|
||||
return global_soul.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not read global SOUL.md: %s", global_soul
|
||||
)
|
||||
|
||||
# 4. Nothing
|
||||
return None
|
||||
|
||||
def resolve_toolsets(
|
||||
self, agent: AgentConfig, platform: str
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Determine which toolsets to load for *agent* on *platform*.
|
||||
|
||||
Returns the agent's explicit ``toolsets`` list if set, otherwise
|
||||
``None`` to let the caller fall back to the platform's default
|
||||
toolset configuration.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
agent:
|
||||
The agent whose toolsets to resolve.
|
||||
platform:
|
||||
The platform name (e.g. ``'telegram'``, ``'local'``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[List[str]]
|
||||
Ordered list of toolset names, or ``None`` for platform default.
|
||||
"""
|
||||
if agent.toolsets is not None:
|
||||
return list(agent.toolsets)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def ensure_workspace(agent: AgentConfig) -> None:
|
||||
"""
|
||||
Create the agent's workspace and session directories if they
|
||||
do not already exist.
|
||||
"""
|
||||
agent.workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent.sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(
|
||||
"Ensured workspace for agent %s: %s", agent.id, agent.workspace_dir
|
||||
)
|
||||
195
gateway/router.py
Normal file
195
gateway/router.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Binding router for multi-agent message routing.
|
||||
|
||||
Maps incoming messages to agent IDs based on platform, chat, guild, and
|
||||
other session-source fields. Bindings are ranked by specificity so that
|
||||
the most precise rule always wins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ── constants ────────────────────────────────────────────────────────────
|
||||
|
||||
PLATFORM_NAMES: set[str] = {
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"whatsapp",
|
||||
"signal",
|
||||
"homeassistant",
|
||||
}
|
||||
|
||||
_KEY_EXPANSION: Dict[str, str] = {
|
||||
"guild": "guild_id",
|
||||
"type": "chat_type",
|
||||
"team": "team_id",
|
||||
"peer": "peer",
|
||||
}
|
||||
|
||||
|
||||
# ── data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Binding:
|
||||
"""A single routing rule that maps a match pattern to an agent."""
|
||||
|
||||
agent_id: str
|
||||
match: Dict[str, str] = field(default_factory=dict)
|
||||
tier: int = 7 # computed priority (1 = most specific)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _assign_tier(match: Dict[str, str]) -> int:
|
||||
"""Return a priority tier (1–7) based on how specific *match* is.
|
||||
|
||||
Lower tier number means higher priority (more specific).
|
||||
|
||||
Tier 1: platform + chat_id (exact channel)
|
||||
Tier 2: platform + peer (exact DM user)
|
||||
Tier 3: platform + guild_id + chat_type
|
||||
Tier 4: platform + (guild_id | team_id)
|
||||
Tier 5: platform + chat_type
|
||||
Tier 6: platform only
|
||||
Tier 7: fallback (empty match)
|
||||
"""
|
||||
keys = set(match.keys()) - {"platform"}
|
||||
|
||||
if not match:
|
||||
return 7
|
||||
if "chat_id" in keys:
|
||||
return 1
|
||||
if "peer" in keys:
|
||||
return 2
|
||||
if "guild_id" in keys and "chat_type" in keys:
|
||||
return 3
|
||||
if "guild_id" in keys or "team_id" in keys:
|
||||
return 4
|
||||
if "chat_type" in keys:
|
||||
return 5
|
||||
if "platform" in match:
|
||||
return 6
|
||||
return 7
|
||||
|
||||
|
||||
def normalize_binding(raw: dict) -> Binding:
|
||||
"""Normalise a shorthand binding dict into a :class:`Binding`.
|
||||
|
||||
Accepted shorthand formats::
|
||||
|
||||
{"agent": "coder", "telegram": "-100123"}
|
||||
→ Binding(agent_id="coder",
|
||||
match={"platform": "telegram", "chat_id": "-100123"})
|
||||
|
||||
{"agent": "assistant", "whatsapp": "*"}
|
||||
→ Binding(agent_id="assistant",
|
||||
match={"platform": "whatsapp"})
|
||||
|
||||
{"agent": "coder", "discord": {"guild": "123", "type": "channel"}}
|
||||
→ Binding(agent_id="coder",
|
||||
match={"platform": "discord",
|
||||
"guild_id": "123", "chat_type": "channel"})
|
||||
"""
|
||||
agent_id: str = raw.get("agent", raw.get("agent_id", ""))
|
||||
if not agent_id:
|
||||
raise ValueError(f"Binding missing 'agent' key: {raw!r}")
|
||||
|
||||
match: Dict[str, str] = {}
|
||||
|
||||
for platform in PLATFORM_NAMES:
|
||||
if platform not in raw:
|
||||
continue
|
||||
|
||||
value: Any = raw[platform]
|
||||
match["platform"] = platform
|
||||
|
||||
if isinstance(value, str):
|
||||
if value != "*":
|
||||
match["chat_id"] = value
|
||||
elif isinstance(value, dict):
|
||||
for short_key, expanded_key in _KEY_EXPANSION.items():
|
||||
if short_key in value:
|
||||
match[expanded_key] = str(value[short_key])
|
||||
# Pass through any keys that are already in expanded form
|
||||
for k, v in value.items():
|
||||
if k not in _KEY_EXPANSION:
|
||||
match[k] = str(v)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Unsupported value type for platform '{platform}': "
|
||||
f"{type(value).__name__}"
|
||||
)
|
||||
break # only one platform key per binding
|
||||
|
||||
tier = _assign_tier(match)
|
||||
return Binding(agent_id=agent_id, match=match, tier=tier)
|
||||
|
||||
|
||||
# ── router ───────────────────────────────────────────────────────────────
|
||||
|
||||
class BindingRouter:
|
||||
"""Route incoming messages to agent IDs based on binding rules.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bindings_config:
|
||||
A list of raw binding dicts (shorthand format).
|
||||
default_agent_id:
|
||||
Fallback agent ID when no binding matches.
|
||||
"""
|
||||
|
||||
def __init__(self, bindings_config: list, default_agent_id: str) -> None:
|
||||
self._default_agent_id: str = default_agent_id
|
||||
self._bindings: List[Binding] = sorted(
|
||||
(normalize_binding(raw) for raw in bindings_config),
|
||||
key=lambda b: b.tier,
|
||||
)
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
platform: str,
|
||||
chat_id: Optional[str] = None,
|
||||
chat_type: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
guild_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the agent ID for the most specific matching binding.
|
||||
|
||||
Iterates bindings in tier order (most specific first). The first
|
||||
match wins. Falls back to *default_agent_id* if nothing matches.
|
||||
"""
|
||||
kwargs: Dict[str, Optional[str]] = {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"chat_type": chat_type,
|
||||
"user_id": user_id,
|
||||
"guild_id": guild_id,
|
||||
"team_id": team_id,
|
||||
}
|
||||
for binding in self._bindings:
|
||||
if self._matches(binding, **kwargs):
|
||||
return binding.agent_id
|
||||
return self._default_agent_id
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _matches(binding: Binding, **kwargs: Optional[str]) -> bool:
|
||||
"""Check whether *binding* matches the supplied keyword arguments.
|
||||
|
||||
Uses AND semantics: every key present in ``binding.match`` must
|
||||
equal the corresponding value in *kwargs*. Keys absent from the
|
||||
binding act as wildcards (always match).
|
||||
"""
|
||||
for key, required_value in binding.match.items():
|
||||
actual = kwargs.get(key)
|
||||
if actual is None:
|
||||
return False
|
||||
if str(actual) != str(required_value):
|
||||
return False
|
||||
return True
|
||||
|
|
@ -161,6 +161,8 @@ from gateway.session import (
|
|||
)
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
from gateway.agent_registry import AgentRegistry, AgentConfig
|
||||
from gateway.router import BindingRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -207,6 +209,24 @@ class GatewayRunner:
|
|||
self._provider_routing = self._load_provider_routing()
|
||||
self._fallback_model = self._load_fallback_model()
|
||||
|
||||
# Load raw config dict for multi-agent support
|
||||
self._raw_config: dict = {}
|
||||
try:
|
||||
import yaml as _y
|
||||
_cfg_path = _hermes_home / 'config.yaml'
|
||||
if _cfg_path.exists():
|
||||
with open(_cfg_path, encoding='utf-8') as _f:
|
||||
self._raw_config = _y.safe_load(_f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Multi-agent registry and router
|
||||
self._agent_registry = AgentRegistry(self._raw_config, global_config=self._raw_config)
|
||||
self._router = BindingRouter(
|
||||
self._raw_config.get('bindings', []),
|
||||
self._agent_registry.get_default().id,
|
||||
)
|
||||
|
||||
# Wire process registry into session store for reset protection
|
||||
from tools.process_registry import process_registry
|
||||
self.session_store = SessionStore(
|
||||
|
|
@ -785,10 +805,19 @@ class GatewayRunner:
|
|||
)
|
||||
return None
|
||||
|
||||
# Resolve which agent handles this message
|
||||
agent_id = self._router.resolve(
|
||||
platform=event.source.platform.value if hasattr(event.source, 'platform') else 'cli',
|
||||
chat_id=getattr(event.source, 'chat_id', None),
|
||||
chat_type=getattr(event.source, 'chat_type', None),
|
||||
user_id=getattr(event.source, 'user_id', None),
|
||||
)
|
||||
agent_config = self._agent_registry.get(agent_id)
|
||||
|
||||
# PRIORITY: If an agent is already running for this session, interrupt it
|
||||
# immediately. This is before command parsing to minimize latency -- the
|
||||
# user's "stop" message reaches the agent as fast as possible.
|
||||
_quick_key = build_session_key(source)
|
||||
_quick_key = build_session_key(source, agent_id=agent_id)
|
||||
if _quick_key in self._running_agents:
|
||||
running_agent = self._running_agents[_quick_key]
|
||||
logger.debug("PRIORITY interrupt for session %s", _quick_key[:20])
|
||||
|
|
@ -806,7 +835,8 @@ class GatewayRunner:
|
|||
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
||||
"personality", "retry", "undo", "sethome", "set-home",
|
||||
"compress", "usage", "insights", "reload-mcp", "reload_mcp",
|
||||
"update", "title", "resume", "provider", "rollback"}
|
||||
"update", "title", "resume", "provider", "rollback",
|
||||
"agents"}
|
||||
if command and command in _known_commands:
|
||||
await self.hooks.emit(f"command:{command}", {
|
||||
"platform": source.platform.value if source.platform else "",
|
||||
|
|
@ -868,6 +898,18 @@ class GatewayRunner:
|
|||
|
||||
if command == "rollback":
|
||||
return await self._handle_rollback_command(event)
|
||||
|
||||
if command == "agents":
|
||||
agent_lines = []
|
||||
for ac in self._agent_registry.list_agents():
|
||||
marker = ' *' if ac.default else ''
|
||||
model = ac.model or '(inherited)'
|
||||
agent_lines.append(f' {ac.id}{marker} {model} {ac.description}')
|
||||
response = f"📋 Agents:\n" + '\n'.join(agent_lines) + f"\n\n🤖 This chat → {agent_id}"
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter:
|
||||
await adapter.send(source.chat_id, response)
|
||||
return response
|
||||
|
||||
# Skill slash commands: /skill-name loads the skill and sends to agent
|
||||
if command:
|
||||
|
|
@ -885,7 +927,7 @@ class GatewayRunner:
|
|||
logger.debug("Skill command check failed (non-fatal): %s", e)
|
||||
|
||||
# Check for pending exec approval responses
|
||||
session_key_preview = build_session_key(source)
|
||||
session_key_preview = build_session_key(source, agent_id=agent_id)
|
||||
if session_key_preview in self._pending_approvals:
|
||||
user_text = event.text.strip().lower()
|
||||
if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
|
||||
|
|
@ -1269,7 +1311,8 @@ class GatewayRunner:
|
|||
history=history,
|
||||
source=source,
|
||||
session_id=session_entry.session_id,
|
||||
session_key=session_key
|
||||
session_key=session_key,
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
response = agent_result.get("final_response", "")
|
||||
|
|
@ -2525,7 +2568,8 @@ class GatewayRunner:
|
|||
history: List[Dict[str, Any]],
|
||||
source: SessionSource,
|
||||
session_id: str,
|
||||
session_key: str = None
|
||||
session_key: str = None,
|
||||
agent_config: AgentConfig = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the agent with the given message and context.
|
||||
|
|
@ -2796,6 +2840,9 @@ class GatewayRunner:
|
|||
combined_ephemeral = context_prompt or ""
|
||||
if self._ephemeral_system_prompt:
|
||||
combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip()
|
||||
# Prepend agent personality if available
|
||||
if agent_config and agent_config.personality:
|
||||
combined_ephemeral = (agent_config.personality + "\n\n" + combined_ephemeral).strip()
|
||||
|
||||
# Re-read .env and config for fresh credentials (gateway is long-lived,
|
||||
# keys may change without restart).
|
||||
|
|
@ -2822,6 +2869,10 @@ class GatewayRunner:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Agent-specific model override
|
||||
if agent_config and agent_config.model:
|
||||
model = agent_config.model
|
||||
|
||||
try:
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
except Exception as exc:
|
||||
|
|
@ -2856,6 +2907,8 @@ class GatewayRunner:
|
|||
honcho_session_key=session_key,
|
||||
session_db=self._session_db,
|
||||
fallback_model=self._fallback_model,
|
||||
agent_tool_policy=agent_config.tool_policy if agent_config else None,
|
||||
agent_workspace=str(agent_config.workspace_dir) if agent_config else None,
|
||||
)
|
||||
|
||||
# Store agent reference for interrupt support
|
||||
|
|
@ -3061,7 +3114,8 @@ class GatewayRunner:
|
|||
history=updated_history,
|
||||
source=source,
|
||||
session_id=session_id,
|
||||
session_key=session_key
|
||||
session_key=session_key,
|
||||
agent_config=agent_config,
|
||||
)
|
||||
finally:
|
||||
# Stop progress sender and interrupt monitor
|
||||
|
|
|
|||
|
|
@ -295,18 +295,26 @@ class SessionEntry:
|
|||
)
|
||||
|
||||
|
||||
def build_session_key(source: SessionSource) -> str:
|
||||
def build_session_key(source: SessionSource, agent_id: str = 'main', dm_scope: str = 'main') -> str:
|
||||
"""Build a deterministic session key from a message source.
|
||||
|
||||
This is the single source of truth for session key construction.
|
||||
WhatsApp DMs include chat_id (multi-user), other DMs do not (single owner).
|
||||
|
||||
Args:
|
||||
source: The session source describing the message origin.
|
||||
agent_id: Agent identifier for multi-agent setups (default 'main').
|
||||
dm_scope: DM scoping strategy. 'per_peer' creates separate sessions
|
||||
per user_id; 'main' (default) uses a single DM session.
|
||||
"""
|
||||
platform = source.platform.value
|
||||
if source.chat_type == "dm":
|
||||
if platform == "whatsapp" and source.chat_id:
|
||||
return f"agent:main:{platform}:dm:{source.chat_id}"
|
||||
return f"agent:main:{platform}:dm"
|
||||
return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}"
|
||||
return f"agent:{agent_id}:{platform}:dm:{source.chat_id}"
|
||||
if dm_scope == "per_peer" and source.user_id:
|
||||
return f"agent:{agent_id}:{platform}:dm:{source.user_id}"
|
||||
return f"agent:{agent_id}:{platform}:dm"
|
||||
return f"agent:{agent_id}:{platform}:{source.chat_type}:{source.chat_id}"
|
||||
|
||||
|
||||
class SessionStore:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue