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

504
gateway/agent_registry.py Normal file
View 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
View 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 (17) 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

View file

@ -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

View file

@ -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: