feat(deploy): per-agent base_url and workspace_path routing

- AgentDefinition gains base_url and workspace_path fields (optional)
- load_agent_registry parses them from matrix-agents.yaml
- _build_platform_from_env uses agent.base_url per agent (falls back to AGENT_BASE_URL)
- _agent_workspace_root() resolves workspace per agent from registry
- _materialize_incoming_attachments saves files to agent workspace_path/incoming/
- send_outgoing accepts workspace_root param; reads outgoing files from agent workspace_path
- dispatch loop computes workspace_root from room agent_id and passes to _send_all
- config/matrix-agents.yaml and example updated with base_url and workspace_path
This commit is contained in:
Mikhail Putilovskij 2026-04-28 03:22:21 +03:00
parent d6b7720eca
commit 4bbae9affa
5 changed files with 108 additions and 21 deletions

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import yaml import yaml
@ -15,6 +15,8 @@ class AgentRegistryError(ValueError):
class AgentDefinition: class AgentDefinition:
agent_id: str agent_id: str
label: str label: str
base_url: str = field(default="")
workspace_path: str = field(default="")
class AgentRegistry: class AgentRegistry:
@ -47,6 +49,15 @@ def _required_text(entry: Mapping[str, object], key: str) -> str:
return text return text
def _optional_text(entry: Mapping[str, object], key: str) -> str:
value = entry.get(key)
if value is None:
return ""
if not isinstance(value, str):
raise AgentRegistryError(f"agent entry field '{key}' must be a string")
return value.strip()
def _load_registry_data(path: str | Path) -> dict[str, object]: def _load_registry_data(path: str | Path) -> dict[str, object]:
try: try:
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
@ -72,10 +83,19 @@ def load_agent_registry(path: str | Path) -> AgentRegistry:
raise AgentRegistryError("each agent entry requires id and label") raise AgentRegistryError("each agent entry requires id and label")
agent_id = _required_text(entry, "id") agent_id = _required_text(entry, "id")
label = _required_text(entry, "label") label = _required_text(entry, "label")
base_url = _optional_text(entry, "base_url")
workspace_path = _optional_text(entry, "workspace_path")
if agent_id in seen: if agent_id in seen:
raise AgentRegistryError(f"duplicate agent id: {agent_id}") raise AgentRegistryError(f"duplicate agent id: {agent_id}")
seen.add(agent_id) seen.add(agent_id)
agents.append(AgentDefinition(agent_id=agent_id, label=label)) agents.append(
AgentDefinition(
agent_id=agent_id,
label=label,
base_url=base_url,
workspace_path=workspace_path,
)
)
user_agents = raw.get("user_agents") user_agents = raw.get("user_agents")
if user_agents is not None: if user_agents is not None:

View file

@ -146,10 +146,11 @@ def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> Pla
prototype_state = PrototypeStateStore() prototype_state = PrototypeStateStore()
registry = _load_agent_registry_from_env(required=True) registry = _load_agent_registry_from_env(required=True)
assert registry is not None assert registry is not None
global_base_url = _agent_base_url_from_env()
delegates = { delegates = {
agent.agent_id: RealPlatformClient( agent.agent_id: RealPlatformClient(
agent_id=agent.agent_id, agent_id=agent.agent_id,
agent_base_url=_agent_base_url_from_env(), agent_base_url=agent.base_url or global_base_url,
prototype_state=prototype_state, prototype_state=prototype_state,
platform="matrix", platform="matrix",
) )
@ -300,6 +301,8 @@ class MatrixBot:
sender, sender,
incoming, incoming,
) )
agent_id = (room_meta or {}).get("agent_id")
workspace_root = self._agent_workspace_root(agent_id)
try: try:
outgoing = await self.runtime.dispatcher.dispatch(incoming) outgoing = await self.runtime.dispatcher.dispatch(incoming)
except PlatformError as exc: except PlatformError as exc:
@ -319,7 +322,7 @@ class MatrixBot:
else: else:
if clear_staged_after_dispatch: if clear_staged_after_dispatch:
await clear_staged_attachments(self.runtime.store, room.room_id, sender) await clear_staged_attachments(self.runtime.store, room.room_id, sender)
await self._send_all(room.room_id, outgoing) await self._send_all(room.room_id, outgoing, workspace_root=workspace_root)
def _is_file_only_event( def _is_file_only_event(
self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand
@ -439,13 +442,27 @@ class MatrixBot:
True, True,
) )
def _agent_workspace_root(self, agent_id: str | None) -> Path:
default = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
if agent_id is None or self.runtime.registry is None:
return default
try:
agent = self.runtime.registry.get(agent_id)
if agent.workspace_path:
return Path(agent.workspace_path)
except Exception:
pass
return default
async def _materialize_incoming_attachments( async def _materialize_incoming_attachments(
self, self,
room_id: str, room_id: str,
matrix_user_id: str, matrix_user_id: str,
incoming: IncomingMessage, incoming: IncomingMessage,
) -> IncomingMessage: ) -> IncomingMessage:
workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) room_meta = await get_room_meta(self.runtime.store, room_id)
agent_id = (room_meta or {}).get("agent_id")
workspace_root = self._agent_workspace_root(agent_id)
materialized = [] materialized = []
for attachment in incoming.attachments: for attachment in incoming.attachments:
materialized.append( materialized.append(
@ -596,9 +613,20 @@ class MatrixBot:
self.runtime.registry, self.runtime.registry,
) )
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None: async def _send_all(
self,
room_id: str,
outgoing: list[OutgoingEvent],
workspace_root: Path | None = None,
) -> None:
for event in outgoing: for event in outgoing:
await send_outgoing(self.client, room_id, event, store=self.runtime.store) await send_outgoing(
self.client,
room_id,
event,
store=self.runtime.store,
workspace_root=workspace_root,
)
async def prepare_live_sync(client: AsyncClient) -> str | None: async def prepare_live_sync(client: AsyncClient) -> str | None:
@ -613,6 +641,7 @@ async def send_outgoing(
room_id: str, room_id: str,
event: OutgoingEvent, event: OutgoingEvent,
store: StateStore | None = None, store: StateStore | None = None,
workspace_root: Path | None = None,
) -> None: ) -> None:
if isinstance(event, OutgoingTyping): if isinstance(event, OutgoingTyping):
await client.room_typing(room_id, event.is_typing, timeout=25000) await client.room_typing(room_id, event.is_typing, timeout=25000)
@ -627,7 +656,9 @@ async def send_outgoing(
room_id, "m.room.message", {"msgtype": "m.text", "body": event.text} room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}
) )
if event.attachments: if event.attachments:
workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) workspace_root = workspace_root or Path(
os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")
)
for attachment in event.attachments: for attachment in event.attachments:
if not attachment.workspace_path: if not attachment.workspace_path:
continue continue

View file

@ -36,6 +36,7 @@ def build_workspace_attachment_path(
filename: str, filename: str,
timestamp: str | None = None, timestamp: str | None = None,
) -> tuple[str, Path]: ) -> tuple[str, Path]:
"""Legacy path builder used when no per-agent workspace_path is configured."""
stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S") stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
safe_user = _sanitize_component(matrix_user_id.lstrip("@")) safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
safe_room = _sanitize_component(room_id.lstrip("!")) safe_room = _sanitize_component(room_id.lstrip("!"))
@ -46,6 +47,21 @@ def build_workspace_attachment_path(
return relative_path.as_posix(), workspace_root / relative_path return relative_path.as_posix(), workspace_root / relative_path
def build_agent_incoming_path(
*,
workspace_root: Path,
filename: str,
timestamp: str | None = None,
) -> tuple[str, Path]:
"""Per-agent path builder: saves to {workspace_root}/incoming/{stamp}-{filename}.
The returned relative path is what gets passed to agent.send_message(attachments=[...]).
"""
stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
safe_name = _sanitize_component(filename) or "attachment.bin"
relative_path = Path("incoming") / f"{stamp}-{safe_name}"
return relative_path.as_posix(), workspace_root / relative_path
async def download_matrix_attachment( async def download_matrix_attachment(
*, *,
client, client,
@ -59,13 +75,23 @@ async def download_matrix_attachment(
return attachment return attachment
filename = _default_filename(attachment) filename = _default_filename(attachment)
relative_path, absolute_path = build_workspace_attachment_path(
workspace_root=workspace_root, if workspace_root.name and str(workspace_root) not in (".", "/workspace", "/agents"):
matrix_user_id=matrix_user_id, # Per-agent workspace configured — use simple incoming/ layout
room_id=room_id, relative_path, absolute_path = build_agent_incoming_path(
filename=filename, workspace_root=workspace_root,
timestamp=timestamp, filename=filename,
) timestamp=timestamp,
)
else:
relative_path, absolute_path = build_workspace_attachment_path(
workspace_root=workspace_root,
matrix_user_id=matrix_user_id,
room_id=room_id,
filename=filename,
timestamp=timestamp,
)
absolute_path.parent.mkdir(parents=True, exist_ok=True) absolute_path.parent.mkdir(parents=True, exist_ok=True)
response = await client.download(attachment.url) response = await client.download(attachment.url)

View file

@ -1,15 +1,18 @@
# Agent registry for the Matrix bot. # Agent registry for the Matrix bot.
# #
# user_agents: maps a Matrix user ID to an agent ID. # user_agents: maps a Matrix user ID to an agent ID.
# If a user is not listed here, the bot uses the first agent from the list below. # If a user is not listed, the bot uses the first agent from the list below.
# Omit this section entirely for a single-agent setup. # Omit this section entirely for a single-agent setup.
# #
# agents: list of available agents. # agents: list of available agents.
# id — must match the agent ID known to the platform (used as key in AgentApi connections) # id — must match the agent ID known to the platform
# label — human-readable name (shown in logs) # label — human-readable name (shown in logs)
# # base_url — HTTP/WS URL of this agent's endpoint
# The agent HTTP endpoint is set globally via AGENT_BASE_URL env var (not per-agent here). # (overrides the global AGENT_BASE_URL env var for this agent)
# File workspace paths are derived from SURFACES_WORKSPACE_DIR env var. # workspace_path — absolute path to this agent's workspace directory inside the bot container
# (the bot saves incoming files here and reads outgoing files from here)
# Example: /agents/0 means the bot mounts the shared volume at /agents/
# and this agent's files live under /agents/0/
user_agents: user_agents:
"@user0:matrix.example.org": agent-0 "@user0:matrix.example.org": agent-0
@ -18,5 +21,10 @@ user_agents:
agents: agents:
- id: agent-0 - id: agent-0
label: "Agent 0" label: "Agent 0"
base_url: "http://lambda.coredump.ru:7000/agent_0/"
workspace_path: "/agents/0"
- id: agent-1 - id: agent-1
label: "Agent 1" label: "Agent 1"
base_url: "http://lambda.coredump.ru:7000/agent_1/"
workspace_path: "/agents/1"

View file

@ -4,3 +4,5 @@
agents: agents:
- id: agent-1 - id: agent-1
label: Surface label: Surface
base_url: "http://lambda.coredump.ru:7000/agent_1/"
workspace_path: "/agents/1"