diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py index c7d1f2d..f75823c 100644 --- a/adapter/matrix/agent_registry.py +++ b/adapter/matrix/agent_registry.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import yaml @@ -15,6 +15,8 @@ class AgentRegistryError(ValueError): class AgentDefinition: agent_id: str label: str + base_url: str = field(default="") + workspace_path: str = field(default="") class AgentRegistry: @@ -47,6 +49,15 @@ def _required_text(entry: Mapping[str, object], key: str) -> str: 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]: try: 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") agent_id = _required_text(entry, "id") label = _required_text(entry, "label") + base_url = _optional_text(entry, "base_url") + workspace_path = _optional_text(entry, "workspace_path") if agent_id in seen: raise AgentRegistryError(f"duplicate agent id: {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") if user_agents is not None: diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py index a36c4b8..cece1f6 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -146,10 +146,11 @@ def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> Pla prototype_state = PrototypeStateStore() registry = _load_agent_registry_from_env(required=True) assert registry is not None + global_base_url = _agent_base_url_from_env() delegates = { agent.agent_id: RealPlatformClient( 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, platform="matrix", ) @@ -300,6 +301,8 @@ class MatrixBot: sender, incoming, ) + agent_id = (room_meta or {}).get("agent_id") + workspace_root = self._agent_workspace_root(agent_id) try: outgoing = await self.runtime.dispatcher.dispatch(incoming) except PlatformError as exc: @@ -319,7 +322,7 @@ class MatrixBot: else: if clear_staged_after_dispatch: 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( self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand @@ -439,13 +442,27 @@ class MatrixBot: 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( self, room_id: str, matrix_user_id: str, incoming: 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 = [] for attachment in incoming.attachments: materialized.append( @@ -596,9 +613,20 @@ class MatrixBot: 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: - 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: @@ -613,6 +641,7 @@ async def send_outgoing( room_id: str, event: OutgoingEvent, store: StateStore | None = None, + workspace_root: Path | None = None, ) -> None: if isinstance(event, OutgoingTyping): 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} ) 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: if not attachment.workspace_path: continue diff --git a/adapter/matrix/files.py b/adapter/matrix/files.py index a736fba..a6210fb 100644 --- a/adapter/matrix/files.py +++ b/adapter/matrix/files.py @@ -36,6 +36,7 @@ def build_workspace_attachment_path( filename: str, timestamp: str | None = None, ) -> 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") safe_user = _sanitize_component(matrix_user_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 +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( *, client, @@ -59,13 +75,23 @@ async def download_matrix_attachment( return attachment filename = _default_filename(attachment) - 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, - ) + + if workspace_root.name and str(workspace_root) not in (".", "/workspace", "/agents"): + # Per-agent workspace configured — use simple incoming/ layout + relative_path, absolute_path = build_agent_incoming_path( + workspace_root=workspace_root, + 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) response = await client.download(attachment.url) diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml index c374bb9..8696def 100644 --- a/config/matrix-agents.example.yaml +++ b/config/matrix-agents.example.yaml @@ -1,15 +1,18 @@ # Agent registry for the Matrix bot. # # 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. # # agents: list of available agents. -# id — must match the agent ID known to the platform (used as key in AgentApi connections) -# label — human-readable name (shown in logs) -# -# The agent HTTP endpoint is set globally via AGENT_BASE_URL env var (not per-agent here). -# File workspace paths are derived from SURFACES_WORKSPACE_DIR env var. +# id — must match the agent ID known to the platform +# label — human-readable name (shown in logs) +# base_url — HTTP/WS URL of this agent's endpoint +# (overrides the global AGENT_BASE_URL env var for this agent) +# 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: "@user0:matrix.example.org": agent-0 @@ -18,5 +21,10 @@ user_agents: agents: - id: agent-0 label: "Agent 0" + base_url: "http://lambda.coredump.ru:7000/agent_0/" + workspace_path: "/agents/0" + - id: agent-1 label: "Agent 1" + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml index bd93d20..3ab9366 100644 --- a/config/matrix-agents.yaml +++ b/config/matrix-agents.yaml @@ -4,3 +4,5 @@ agents: - id: agent-1 label: Surface + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1"