feat: commit staged matrix attachments on next message
This commit is contained in:
parent
f111ed3348
commit
323a6d3144
3 changed files with 155 additions and 23 deletions
53
README.md
53
README.md
|
|
@ -7,7 +7,7 @@
|
||||||
| Поверхность | Статус |
|
| Поверхность | Статус |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` |
|
| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` |
|
||||||
| Matrix | ✅ Рабочий прототип, подключается к реальному агенту |
|
| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -69,8 +69,8 @@ surfaces-bot/
|
||||||
- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
|
- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
|
||||||
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
|
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
|
||||||
- **Текущее ограничение** — encrypted DM пока не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
|
- **Текущее ограничение** — encrypted DM пока не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
|
||||||
- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` требует `AGENT_WS_URL=ws://host:port/agent_ws/`
|
- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/`
|
||||||
- **Ограничения real backend** — пока это текстовый direct-agent прототип без вложений и без асинхронных callbacks; локальные настройки и user-state хранятся в `PrototypeStateStore`
|
- **Ограничения real backend** — локальный runtime использует shared `/workspace`, а файлы передаются как относительные пути в `attachments`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -90,6 +90,7 @@ class PlatformClient(Protocol):
|
||||||
Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
|
Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
|
||||||
|
|
||||||
Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`.
|
Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`.
|
||||||
|
Файловый контракт уже path-based: бот пишет файлы в shared `/workspace` и передаёт платформе относительные пути в `attachments`.
|
||||||
Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.
|
Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -120,32 +121,38 @@ MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=...
|
||||||
# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent)
|
# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent)
|
||||||
MATRIX_PLATFORM_BACKEND=real
|
MATRIX_PLATFORM_BACKEND=real
|
||||||
|
|
||||||
# URL WebSocket endpoint platform-agent (только при MATRIX_PLATFORM_BACKEND=real)
|
# compose runtime: platform-agent service name + shared /workspace
|
||||||
AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/
|
AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/
|
||||||
AGENT_BASE_URL=http://127.0.0.1:8000
|
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
|
```bash
|
||||||
cd external/platform-agent
|
docker compose up --build
|
||||||
|
|
||||||
# Создать .env с параметрами LLM провайдера
|
|
||||||
cat > .env <<EOF
|
|
||||||
PROVIDER_MODEL=openai/gpt-4o-mini
|
|
||||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
|
||||||
PROVIDER_API_KEY=sk-or-...
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Запустить
|
|
||||||
uv run uvicorn src.main:app --host 127.0.0.1 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверить что работает: `curl http://127.0.0.1:8000/agent_ws/` должен вернуть ответ об апгрейде до WebSocket.
|
Compose использует локальные директории `external/platform-agent` и `external/platform-agent_api` как источник кода для агента.
|
||||||
|
Matrix бот подключается к `platform-agent` по service name, а не к отдельно запущенному `localhost`.
|
||||||
|
|
||||||
### 4. Запуск бота
|
### 4.1. Staged attachments в Matrix
|
||||||
|
|
||||||
|
Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу.
|
||||||
|
Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения.
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
|
||||||
|
- `!list` — показать staged вложения
|
||||||
|
- `!remove <n>` — удалить вложение по номеру
|
||||||
|
- `!remove all` — очистить все staged вложения
|
||||||
|
|
||||||
|
Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами.
|
||||||
|
|
||||||
|
### 4. Запуск бота вручную
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Первый запуск или сброс состояния
|
# Первый запуск или сброс состояния
|
||||||
|
|
@ -184,6 +191,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot
|
||||||
| Состояние контекста | `!context` | Текущая сессия и список сохранений |
|
| Состояние контекста | `!context` | Текущая сессия и список сохранений |
|
||||||
| Справка | `!help` | |
|
| Справка | `!help` | |
|
||||||
| Подтверждения | `!yes` / `!no` | Для опасных действий |
|
| Подтверждения | `!yes` / `!no` | Для опасных действий |
|
||||||
|
| Staged вложения | `!list`, `!remove <n>`, `!remove all` | Файлы без текстовой инструкции ставятся в очередь до следующего сообщения |
|
||||||
|
|
||||||
### Не работает — блокеры на стороне platform-agent
|
### Не работает — блокеры на стороне 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` с общим хранилищем. |
|
| `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. |
|
||||||
| Счётчик токенов в `!context` | platform-agent отдаёт `tokens_used=0` хардкодом в `MsgEventEnd`. Наш код перехватывает значение корректно. |
|
| Счётчик токенов в `!context` | platform-agent отдаёт `tokens_used=0` хардкодом в `MsgEventEnd`. Наш код перехватывает значение корректно. |
|
||||||
| `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. |
|
| `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. |
|
||||||
| Файловые вложения | Нет API загрузки файлов в область видимости агента. ТЗ передано платформе. |
|
|
||||||
| Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. |
|
| Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. |
|
||||||
| E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. |
|
| E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. |
|
||||||
|
|
||||||
|
|
@ -201,7 +208,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot
|
||||||
| Функция | Статус |
|
| Функция | Статус |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. |
|
| `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. |
|
||||||
| Вложения (изображения, документы) | Только текстовые сообщения в текущем MVP. |
|
| Вложения без текстовой инструкции | Поддержан staged UX только для Matrix. Для других поверхностей ещё не перенесено. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ from core.chat import ChatManager
|
||||||
from core.handler import EventDispatcher
|
from core.handler import EventDispatcher
|
||||||
from core.handlers import register_all
|
from core.handlers import register_all
|
||||||
from core.protocol import (
|
from core.protocol import (
|
||||||
|
Attachment,
|
||||||
IncomingCommand,
|
IncomingCommand,
|
||||||
IncomingMessage,
|
IncomingMessage,
|
||||||
OutgoingEvent,
|
OutgoingEvent,
|
||||||
|
|
@ -246,6 +247,13 @@ class MatrixBot:
|
||||||
sender,
|
sender,
|
||||||
incoming,
|
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:
|
try:
|
||||||
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
||||||
except PlatformError as exc:
|
except PlatformError as exc:
|
||||||
|
|
@ -262,6 +270,9 @@ class MatrixBot:
|
||||||
text="Сервис временно недоступен. Попробуйте ещё раз позже.",
|
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)
|
await self._send_all(room.room_id, outgoing)
|
||||||
|
|
||||||
def _is_file_only_event(
|
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(
|
async def _materialize_incoming_attachments(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,89 @@ async def test_staged_attachment_commands_are_scoped_by_room_and_user():
|
||||||
assert "bob-room-one.pdf" not in body
|
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():
|
async def test_bot_keeps_commands_on_local_chat_id():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue