docs(deploy): finalize multi-agent surface image handoff

This commit is contained in:
Mikhail Putilovskij 2026-04-28 20:11:27 +03:00
parent 51241d79e0
commit 5b537880ae
11 changed files with 361 additions and 27 deletions

View file

@ -6,11 +6,16 @@ __pycache__/
.ruff_cache/ .ruff_cache/
.venv/ .venv/
.worktrees/ .worktrees/
external/
.planning/
docs/superpowers/
tests/
# Local runtime state must not be baked into the image. # Local runtime state must not be baked into the image.
lambda_matrix.db lambda_matrix.db
matrix_store/ matrix_store/
lambda_bot.db lambda_bot.db
config/matrix-agents.yaml
# Local environment and editor state # Local environment and editor state
.env .env

View file

@ -8,6 +8,13 @@ MATRIX_PASSWORD=your_password_here
# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) # Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only)
MATRIX_PLATFORM_BACKEND=real MATRIX_PLATFORM_BACKEND=real
# Published surface image used by docker-compose.prod.yml.
# Must point to a Docker Hub/registry namespace where you have push/pull access.
SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
# platform/agent_api ref used when building a surface image
LAMBDA_AGENT_API_REF=master
# Path to agent registry inside the container (mounted via ./config:/app/config:ro) # Path to agent registry inside the container (mounted via ./config:/app/config:ro)
MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml
@ -16,7 +23,8 @@ MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml
# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml # Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml
AGENT_BASE_URL=http://your-agent-host:8000 AGENT_BASE_URL=http://your-agent-host:8000
# Shared volume path inside the bot container (default: /agents) # Shared volume path inside the bot container (default: /agents).
# For multi-agent production, each agent gets a subdirectory such as /agents/0.
SURFACES_WORKSPACE_DIR=/agents SURFACES_WORKSPACE_DIR=/agents
# Docker volume names (created automatically on first run) # Docker volume names (created automatically on first run)

View file

@ -1,4 +1,4 @@
FROM python:3.11-slim FROM python:3.11-slim AS base
WORKDIR /app WORKDIR /app
@ -6,8 +6,11 @@ ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
ENV UV_PROJECT_ENVIRONMENT=/usr/local ENV UV_PROJECT_ENVIRONMENT=/usr/local
# Install uv for dependency management inside the container. # Install uv and git for reproducible platform SDK installation.
RUN pip install --no-cache-dir uv RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Copy dependency manifests first for layer caching. # Copy dependency manifests first for layer caching.
COPY pyproject.toml uv.lock* ./ COPY pyproject.toml uv.lock* ./
@ -15,15 +18,27 @@ COPY pyproject.toml uv.lock* ./
# Install project dependencies into the system environment. # Install project dependencies into the system environment.
RUN uv sync --no-dev --no-install-project --frozen RUN uv sync --no-dev --no-install-project --frozen
# Copy project source after dependency layers. FROM base AS development
COPY . .
# Install the project itself. # Local fullstack/dev builds can override the SDK with a checked-out agent_api
# build context, matching platform-agent's development Dockerfile pattern.
COPY --from=agent_api . /agent_api/
RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/
COPY . .
RUN uv sync --no-dev --frozen RUN uv sync --no-dev --frozen
# Install lambda_agent_api from the vendored source tree. CMD ["python", "-m", "adapter.matrix.bot"]
# --ignore-requires-python: the package declares python<3.12 but works fine on 3.11;
# the guard exists for its own dev tooling, not the runtime API surface we use. FROM base AS production
RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
# Production builds follow the platform-agent pattern: install the API SDK from
# the platform Git repository instead of relying on local external/ clones.
ARG LAMBDA_AGENT_API_REF=master
RUN python -m pip install --no-cache-dir --ignore-requires-python \
"git+https://git.lambda.coredump.ru/platform/agent_api.git@${LAMBDA_AGENT_API_REF}"
COPY . .
RUN uv sync --no-dev --frozen
CMD ["python", "-m", "adapter.matrix.bot"] CMD ["python", "-m", "adapter.matrix.bot"]

View file

@ -4,7 +4,7 @@ Matrix-бот для взаимодействия пользователя с AI
## Интеграция для платформы ## Интеграция для платформы
Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Production target — один surface container на 25-30 внешних agent containers/services.
### Что бот ожидает от вас ### Что бот ожидает от вас
@ -37,10 +37,11 @@ Bot container Agent containers
### Минимальный чеклист ### Минимальный чеклист
- [ ] Взять опубликованный image `SURFACES_BOT_IMAGE` или собрать production image из этого репозитория
- [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей - [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей
- [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml` - [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`
- [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace` - [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace`
- [ ] Добавить бот-сервис в свой compose (используйте `docker-compose.prod.yml` как шаблон сервиса) - [ ] Добавить bot-only service в свой compose; агенты в этот compose не входят и управляются платформой
--- ---
@ -94,6 +95,7 @@ cp .env.example .env
| `MATRIX_USER_ID` | ✓ | `@bot:example.org` | | `MATRIX_USER_ID` | ✓ | `@bot:example.org` |
| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) | | `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) |
| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна | | `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна |
| `SURFACES_BOT_IMAGE` | ✓ | Docker image поверхности: `mput1/surfaces-bot:latest` |
| `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` | | `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` |
| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` | | `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` |
| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) | | `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) |
@ -117,31 +119,57 @@ agents:
label: "Agent 1" label: "Agent 1"
base_url: "http://lambda.coredump.ru:7000/agent_1/" base_url: "http://lambda.coredump.ru:7000/agent_1/"
workspace_path: "/agents/1" workspace_path: "/agents/1"
- id: agent-2
label: "Agent 2"
base_url: "http://lambda.coredump.ru:7000/agent_2/"
workspace_path: "/agents/2"
``` ```
- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент. - `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент.
- `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy). - `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy).
- `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume. - `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume.
Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, агент пишет исходящие в свой `/workspace/`. Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, агент пишет исходящие в свой `/workspace/`.
- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
Полный пример с комментариями: `config/matrix-agents.example.yaml` Полный пример с комментариями: `config/matrix-agents.example.yaml`
### Production (bot-only) ### Production (bot-only)
`docker-compose.prod.yml`шаблон сервиса `matrix-bot`. Платформа добавляет этот сервис в свой compose рядом с агент-контейнерами, монтирует shared volume и задаёт переменные окружения. `docker-compose.prod.yml`bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры.
Для изолированного запуска бота без агентов (smoke-тест): Для запуска опубликованного image:
```bash ```bash
docker compose --env-file .env -f docker-compose.prod.yml up -d --build export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
docker compose --env-file .env -f docker-compose.prod.yml up -d
``` ```
Опубликованный image:
```text
mput1/surfaces-bot:latest
sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be
```
Для сборки и публикации surface image:
```bash
docker login
export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
docker build --target production \
--build-arg LAMBDA_AGENT_API_REF=master \
-t "$SURFACES_BOT_IMAGE" .
docker push "$SURFACES_BOT_IMAGE"
```
Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`.
### Fullstack E2E (bot + agent) ### Fullstack E2E (bot + agent)
```bash ```bash
docker compose --env-file .env -f docker-compose.fullstack.yml up --build docker compose --env-file .env -f docker-compose.fullstack.yml up --build
``` ```
Поднимает `matrix-bot` вместе с локальным `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. Поднимает `matrix-bot` вместе с локальным `platform-agent`. Это internal harness, не production topology на 25-30 агентов. `matrix-bot` собирается через Dockerfile target `development` и локальный `external/platform-agent_api` context, как в dev-сборке `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте.
### Сброс состояния (локально) ### Сброс состояния (локально)
@ -159,8 +187,8 @@ Bot (/agents) Agent (/workspace = /agents/N/)
/agents/0/output/ ←────────────────────────────────→ /workspace/output/ /agents/0/output/ ←────────────────────────────────→ /workspace/output/
``` ```
- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/incoming/{stamp}-{file}`, передаёт агенту `attachments=["incoming/{stamp}-{file}"]` - **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/incoming/{stamp}-{file}`, например `/agents/17/incoming/report.pdf`, и передаёт агенту `attachments=["incoming/{stamp}-{file}"]`
- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/output/file`, бот читает из `{workspace_path}/output/file` и отправляет пользователю как Matrix file message - **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/output/file`, бот читает из `{workspace_path}/output/file`, например `/agents/17/output/file`, и отправляет пользователю как Matrix file message
- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` - `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
--- ---

View file

@ -1,4 +1,6 @@
# Agent registry for the Matrix bot. # Agent registry for the Matrix bot.
# Production target: one surface bot routes to 25-30 externally managed agents.
# Keep adding entries with the same base_url/workspace_path pattern.
# #
# 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, 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.
@ -17,6 +19,7 @@
user_agents: user_agents:
"@user0:matrix.example.org": agent-0 "@user0:matrix.example.org": agent-0
"@user1:matrix.example.org": agent-1 "@user1:matrix.example.org": agent-1
"@user2:matrix.example.org": agent-2
agents: agents:
- id: agent-0 - id: agent-0
@ -28,3 +31,14 @@ agents:
label: "Agent 1" label: "Agent 1"
base_url: "http://lambda.coredump.ru:7000/agent_1/" base_url: "http://lambda.coredump.ru:7000/agent_1/"
workspace_path: "/agents/1" workspace_path: "/agents/1"
- id: agent-2
label: "Agent 2"
base_url: "http://lambda.coredump.ru:7000/agent_2/"
workspace_path: "/agents/2"
# Continue the same pattern through agent-29 for a 25-30 agent deployment:
# - id: agent-29
# label: "Agent 29"
# base_url: "http://lambda.coredump.ru:7000/agent_29/"
# workspace_path: "/agents/29"

View file

@ -3,6 +3,16 @@ services:
extends: extends:
file: docker-compose.prod.yml file: docker-compose.prod.yml
service: matrix-bot service: matrix-bot
build:
context: .
dockerfile: Dockerfile
target: development
args:
LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
additional_contexts:
agent_api: ./external/platform-agent_api
tags:
- ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev}
environment: environment:
AGENT_BASE_URL: http://platform-agent:8000 AGENT_BASE_URL: http://platform-agent:8000
depends_on: depends_on:

View file

@ -1,6 +1,6 @@
services: services:
matrix-bot: matrix-bot:
build: . image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}"
environment: environment:
MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-} MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-}
MATRIX_USER_ID: ${MATRIX_USER_ID:-} MATRIX_USER_ID: ${MATRIX_USER_ID:-}

View file

@ -7,10 +7,10 @@
## Compose Artifacts ## Compose Artifacts
- **Production deploy:** `docker-compose.prod.yml` - **Production deploy:** `docker-compose.prod.yml`
Bot-only handoff. Поднимает только `matrix-bot`, монтирует shared volume в `/agents`. Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`.
Платформа предоставляет агент-контейнеры отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`. Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`.
- **Internal full-stack E2E:** `docker-compose.fullstack.yml` - **Internal full-stack E2E:** `docker-compose.fullstack.yml`
Внутренний harness для тестирования. Поднимает `matrix-bot` и один `platform-agent`, health-gated startup. Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup.
Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`. Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`.
@ -33,7 +33,7 @@ lambda.coredump.ru
``` ```
- **Один инстанс Matrix-бота** обслуживает всех пользователей. - **Один инстанс Matrix-бота** обслуживает всех пользователей.
- **Один агент-контейнер на пользователя.** Изоляция по agent_id, не через chat_id внутри одного инстанса. - **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance.
- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу. - **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу.
--- ---
@ -58,12 +58,49 @@ agents:
label: "Agent 1" label: "Agent 1"
base_url: "http://lambda.coredump.ru:7000/agent_1/" base_url: "http://lambda.coredump.ru:7000/agent_1/"
workspace_path: "/agents/1" workspace_path: "/agents/1"
- id: agent-2
label: "Agent 2"
base_url: "http://lambda.coredump.ru:7000/agent_2/"
workspace_path: "/agents/2"
``` ```
- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка. - `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка.
- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi. - `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi.
- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume). - `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume).
Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, читает исходящие из `{workspace_path}/`. Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, читает исходящие из `{workspace_path}/`.
- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
## Surface Image Build Contract
Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context.
```bash
docker login
export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
docker build --target production \
--build-arg LAMBDA_AGENT_API_REF=master \
-t "$SURFACES_BOT_IMAGE" .
docker push "$SURFACES_BOT_IMAGE"
```
Published image:
```text
mput1/surfaces-bot:latest
sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be
```
`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image:
```bash
git+https://git.lambda.coredump.ru/platform/agent_api.git
```
Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK.
--- ---

View file

@ -15,8 +15,10 @@ from nio import (
from nio.api import RoomVisibility from nio.api import RoomVisibility
from nio.responses import SyncResponse from nio.responses import SyncResponse
from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
from adapter.matrix.handlers.auth import handle_invite from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.routed_platform import RoutedPlatformClient
from adapter.matrix.store import ( from adapter.matrix.store import (
add_staged_attachment, add_staged_attachment,
get_platform_chat_id, get_platform_chat_id,
@ -36,7 +38,6 @@ from core.protocol import (
) )
from sdk.interface import PlatformError from sdk.interface import PlatformError
from sdk.mock import MockPlatformClient from sdk.mock import MockPlatformClient
from adapter.matrix.routed_platform import RoutedPlatformClient
async def test_matrix_dispatcher_registers_custom_handlers(): async def test_matrix_dispatcher_registers_custom_handlers():
@ -107,7 +108,10 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
assert client.room_create.await_count >= 1 assert client.room_create.await_count >= 1
client.room_put_state.assert_awaited_once() client.room_put_state.assert_awaited_once()
put_call = client.room_put_state.call_args put_call = client.room_put_state.call_args
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" assert (
put_call.kwargs.get("room_id") == "!space:example"
or put_call.args[0] == "!space:example"
)
chats = await runtime.chat_mgr.list_active("u1") chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C7"] assert [c.chat_id for c in chats] == ["C7"]
assert [c.surface_ref for c in chats] == ["!r2:example"] assert [c.surface_ref for c in chats] == ["!r2:example"]
@ -333,6 +337,119 @@ async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, m
bot._send_all.assert_not_awaited() bot._send_all.assert_not_awaited()
async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch):
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
runtime = build_runtime(platform=MockPlatformClient())
runtime.registry = AgentRegistry(
[
AgentDefinition(
agent_id="agent-17",
label="Agent 17",
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
)
await set_room_meta(
runtime.store,
"!chat17:example.org",
{
"chat_id": "C17",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "17",
"agent_id": "agent-17",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat17:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="report.pdf",
msgtype="m.file",
replyto_event_id=None,
url="mxc://server/id",
mimetype="application/pdf",
)
await bot.on_room_message(room, event)
staged = await get_staged_attachments(
runtime.store, "!chat17:example.org", "@alice:example.org"
)
assert staged[0]["workspace_path"].startswith("incoming/")
assert (
tmp_path / "agents" / "17" / staged[0]["workspace_path"]
).read_bytes() == b"%PDF-1.7"
async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
output_file = tmp_path / "agents" / "17" / "output" / "result.txt"
output_file.parent.mkdir(parents=True)
output_file.write_text("ready", encoding="utf-8")
runtime = build_runtime(platform=MockPlatformClient())
runtime.registry = AgentRegistry(
[
AgentDefinition(
agent_id="agent-17",
label="Agent 17",
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
)
await set_room_meta(
runtime.store,
"!chat17:example.org",
{
"chat_id": "C17",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "17",
"agent_id": "agent-17",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})),
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(
return_value=[
OutgoingMessage(
chat_id="C17",
text="Файл готов",
attachments=[
Attachment(
type="document",
filename="result.txt",
mime_type="text/plain",
workspace_path="output/result.txt",
)
],
)
]
)
room = SimpleNamespace(room_id="!chat17:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="сделай отчёт",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
uploaded_handle = client.upload.await_args.args[0]
assert uploaded_handle.name == str(output_file)
assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result"
async def test_file_only_event_is_staged_and_does_not_dispatch(): async def test_file_only_event_is_staged_and_does_not_dispatch():
runtime = build_runtime(platform=MockPlatformClient()) runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())

View file

@ -3,7 +3,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from adapter.matrix.files import build_workspace_attachment_path, download_matrix_attachment from adapter.matrix.files import (
build_agent_incoming_path,
build_workspace_attachment_path,
download_matrix_attachment,
)
from core.protocol import Attachment from core.protocol import Attachment
@ -65,3 +69,37 @@ def test_build_workspace_attachment_path_keeps_room_safe_agents_relative_contrac
) )
assert not Path(rel_path).is_absolute() assert not Path(rel_path).is_absolute()
assert abs_path == tmp_path / "agents" / "7" / rel_path assert abs_path == tmp_path / "agents" / "7" / rel_path
def test_build_agent_incoming_path_uses_agent_workspace_volume(tmp_path: Path):
rel_path, abs_path = build_agent_incoming_path(
workspace_root=tmp_path / "agents" / "17",
filename="quarterly status.pdf",
timestamp="20260428-110000",
)
assert rel_path == "incoming/20260428-110000-quarterly_status.pdf"
assert abs_path == tmp_path / "agents" / "17" / rel_path
async def test_download_matrix_attachment_uses_agent_workspace_incoming_dir(tmp_path: Path):
async def download(url: str):
assert url == "mxc://server/id"
return SimpleNamespace(body=b"%PDF-1.7")
saved = await download_matrix_attachment(
client=SimpleNamespace(download=download),
workspace_root=tmp_path / "agents" / "17",
matrix_user_id="@alice:example.org",
room_id="!room:example.org",
attachment=Attachment(
type="document",
url="mxc://server/id",
filename="report.pdf",
mime_type="application/pdf",
),
timestamp="20260428-110000",
)
assert saved.workspace_path == "incoming/20260428-110000-report.pdf"
assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
def _compose(path: str) -> dict:
return yaml.safe_load((ROOT / path).read_text(encoding="utf-8"))
def test_prod_compose_uses_registry_image_not_local_build():
prod = _compose("docker-compose.prod.yml")
service = prod["services"]["matrix-bot"]
assert "image" in service
assert "build" not in service
assert service["image"].startswith("${SURFACES_BOT_IMAGE:?")
def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context():
fullstack = _compose("docker-compose.fullstack.yml")
service = fullstack["services"]["matrix-bot"]
assert service["build"]["target"] == "development"
assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api"
assert service["extends"]["file"] == "docker-compose.prod.yml"
def test_dockerfile_production_build_does_not_require_local_external_tree():
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "/app/external/platform-agent_api" not in dockerfile
assert "external/platform-agent_api" not in dockerfile
assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile
assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile
assert "uv pip install --system --ignore-requires-python" not in dockerfile
def test_dockerignore_excludes_local_only_and_runtime_artifacts():
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
assert "external/" in dockerignore
assert ".planning/" in dockerignore
assert "config/matrix-agents.yaml" in dockerignore
assert ".env" in dockerignore
def test_agent_registry_example_documents_multi_agent_volume_contract():
registry = yaml.safe_load(
(ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8")
)
agents = registry["agents"]
assert len(agents) >= 3
assert len({agent["id"] for agent in agents}) == len(agents)
assert len({agent["workspace_path"] for agent in agents}) == len(agents)
for index, agent in enumerate(agents):
assert agent["base_url"].endswith(f"/agent_{index}/")
assert agent["workspace_path"] == f"/agents/{index}"