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:
parent
d6b7720eca
commit
4bbae9affa
5 changed files with 108 additions and 21 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue