From 323a6d3144f838b668fc0a4537766bdc9374cb35 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Mon, 20 Apr 2026 21:39:37 +0300 Subject: [PATCH] feat: commit staged matrix attachments on next message --- README.md | 53 +++++++++------- adapter/matrix/bot.py | 42 +++++++++++++ tests/adapter/matrix/test_dispatcher.py | 83 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 82cd55c..a9d7f71 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ | Поверхность | Статус | |---|---| | Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` | -| Matrix | ✅ Рабочий прототип, подключается к реальному агенту | +| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` | --- @@ -69,8 +69,8 @@ surfaces-bot/ - **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher` - **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта - **Текущее ограничение** — encrypted DM пока не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота -- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` требует `AGENT_WS_URL=ws://host:port/agent_ws/` -- **Ограничения real backend** — пока это текстовый direct-agent прототип без вложений и без асинхронных callbacks; локальные настройки и user-state хранятся в `PrototypeStateStore` +- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/` +- **Ограничения real backend** — локальный runtime использует shared `/workspace`, а файлы передаются как относительные пути в `attachments` --- @@ -90,6 +90,7 @@ class PlatformClient(Protocol): Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`. +Файловый контракт уже path-based: бот пишет файлы в shared `/workspace` и передаёт платформе относительные пути в `attachments`. Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. --- @@ -120,32 +121,38 @@ MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=... # Выбор backend: mock (по умолчанию) или real (подключение к platform-agent) MATRIX_PLATFORM_BACKEND=real -# URL WebSocket endpoint platform-agent (только при MATRIX_PLATFORM_BACKEND=real) -AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/ -AGENT_BASE_URL=http://127.0.0.1:8000 +# compose runtime: platform-agent service name + shared /workspace +AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/ +AGENT_BASE_URL=http://platform-agent:8000 +SURFACES_WORKSPACE_DIR=/workspace ``` -### 3. Запуск platform-agent (для real backend) +### 3. Compose runtime -platform-agent — отдельный репозиторий, сейчас клонируется в `external/platform-agent`. +Root `docker-compose.yml` теперь является основным локальным runtime для Matrix и platform-agent. +Он поднимает `matrix-bot`, `platform-agent` и общий volume `/workspace`. ```bash -cd external/platform-agent - -# Создать .env с параметрами LLM провайдера -cat > .env <` — удалить вложение по номеру +- `!remove all` — очистить все staged вложения + +Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами. + +### 4. Запуск бота вручную ```bash # Первый запуск или сброс состояния @@ -184,6 +191,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot | Состояние контекста | `!context` | Текущая сессия и список сохранений | | Справка | `!help` | | | Подтверждения | `!yes` / `!no` | Для опасных действий | +| Staged вложения | `!list`, `!remove `, `!remove all` | Файлы без текстовой инструкции ставятся в очередь до следующего сообщения | ### Не работает — блокеры на стороне platform-agent @@ -192,7 +200,6 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot | `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. | | Счётчик токенов в `!context` | platform-agent отдаёт `tokens_used=0` хардкодом в `MsgEventEnd`. Наш код перехватывает значение корректно. | | `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. | -| Файловые вложения | Нет API загрузки файлов в область видимости агента. ТЗ передано платформе. | | Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. | | E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. | @@ -201,7 +208,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot | Функция | Статус | |---|---| | `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. | -| Вложения (изображения, документы) | Только текстовые сообщения в текущем MVP. | +| Вложения без текстовой инструкции | Поддержан staged UX только для Matrix. Для других поверхностей ещё не перенесено. | --- diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py index 39c1c77..bd3934a 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -46,6 +46,7 @@ from core.chat import ChatManager from core.handler import EventDispatcher from core.handlers import register_all from core.protocol import ( + Attachment, IncomingCommand, IncomingMessage, OutgoingEvent, @@ -246,6 +247,13 @@ class MatrixBot: sender, incoming, ) + clear_staged_after_dispatch = False + if isinstance(incoming, IncomingMessage) and incoming.text: + incoming, clear_staged_after_dispatch = await self._merge_staged_attachments( + room.room_id, + sender, + incoming, + ) try: outgoing = await self.runtime.dispatcher.dispatch(incoming) except PlatformError as exc: @@ -262,6 +270,9 @@ class MatrixBot: text="Сервис временно недоступен. Попробуйте ещё раз позже.", ) ] + 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) def _is_file_only_event( @@ -351,6 +362,37 @@ class MatrixBot: ) ] + async def _merge_staged_attachments( + self, + room_id: str, + user_id: str, + incoming: IncomingMessage, + ) -> tuple[IncomingMessage, bool]: + staged = await get_staged_attachments(self.runtime.store, room_id, user_id) + if not staged: + return incoming, False + attachments = [ + Attachment( + type=item.get("type", "document"), + url=item.get("url"), + filename=item.get("filename"), + mime_type=item.get("mime_type"), + workspace_path=item.get("workspace_path"), + ) + for item in staged + ] + return ( + IncomingMessage( + user_id=incoming.user_id, + platform=incoming.platform, + chat_id=incoming.chat_id, + text=incoming.text, + attachments=attachments, + reply_to=incoming.reply_to, + ), + True, + ) + async def _materialize_incoming_attachments( self, room_id: str, diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index 0c92686..b50dfe0 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -527,6 +527,89 @@ async def test_staged_attachment_commands_are_scoped_by_room_and_user(): assert "bob-room-one.pdf" not in body +async def test_next_normal_message_commits_staged_attachments(): + runtime = build_runtime(platform=MockPlatformClient()) + await set_room_meta( + runtime.store, + "!r:example.org", + { + "chat_id": "C1", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "matrix:ctx-1", + }, + ) + await add_staged_attachment( + runtime.store, + "!r:example.org", + "@alice:example.org", + { + "type": "document", + "filename": "report.pdf", + "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf", + "mime_type": "application/pdf", + }, + ) + client = SimpleNamespace(user_id="@bot:example.org") + bot = MatrixBot(client, runtime) + bot._send_all = AsyncMock() + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + room = SimpleNamespace(room_id="!r:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="Проанализируй", + msgtype="m.text", + replyto_event_id=None, + ) + + await bot.on_room_message(room, event) + + dispatched = runtime.dispatcher.dispatch.await_args.args[0] + assert isinstance(dispatched, IncomingMessage) + assert dispatched.text == "Проанализируй" + assert [a.workspace_path for a in dispatched.attachments] == [ + "surfaces/matrix/alice/r/inbox/report.pdf" + ] + assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == [] + + +async def test_failed_commit_preserves_staged_attachments(): + runtime = build_runtime(platform=MockPlatformClient()) + await set_room_meta( + runtime.store, + "!r:example.org", + { + "chat_id": "C1", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "matrix:ctx-1", + }, + ) + await add_staged_attachment( + runtime.store, + "!r:example.org", + "@alice:example.org", + { + "type": "document", + "filename": "report.pdf", + "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf", + }, + ) + client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) + bot = MatrixBot(client, runtime) + runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom")) + room = SimpleNamespace(room_id="!r:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="Проанализируй", + msgtype="m.text", + replyto_event_id=None, + ) + + await bot.on_room_message(room, event) + + staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") + assert [item["filename"] for item in staged] == ["report.pdf"] + + async def test_bot_keeps_commands_on_local_chat_id(): runtime = build_runtime(platform=MockPlatformClient()) await set_room_meta(