feat(deploy): finalize MVP deployment and file transfer approach
This commit is contained in:
parent
6369721876
commit
0f79494fbe
43 changed files with 3078 additions and 645 deletions
|
|
@ -68,7 +68,7 @@ agents:
|
|||
- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка.
|
||||
- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi.
|
||||
- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume).
|
||||
Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, читает исходящие из `{workspace_path}/`.
|
||||
Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`.
|
||||
- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
|
||||
|
||||
## Surface Image Build Contract
|
||||
|
|
@ -89,7 +89,7 @@ Published image:
|
|||
|
||||
```text
|
||||
mput1/surfaces-bot:latest
|
||||
sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be
|
||||
sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
|
||||
```
|
||||
|
||||
`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
|
||||
|
|
@ -153,14 +153,15 @@ AgentApi(
|
|||
### Пользователь → Агент (входящий файл)
|
||||
|
||||
1. Matrix-бот получает файл от пользователя
|
||||
2. Сохраняет в workspace агента: `/agents/{N}/incoming/{filename}`
|
||||
3. Вызывает `agent.send_message(text, attachments=["incoming/filename"])`
|
||||
2. Сохраняет в workspace агента: `/agents/{N}/{filename}`
|
||||
3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext`
|
||||
4. Вызывает `agent.send_message(text, attachments=["filename"])`
|
||||
— путь относительно `/workspace` агента
|
||||
|
||||
### Агент → Пользователь (исходящий файл)
|
||||
|
||||
1. Агент эмитит `MsgEventSendFile(path="output/report.pdf")`
|
||||
2. Matrix-бот читает файл: `/agents/{N}/output/report.pdf`
|
||||
1. Агент эмитит `MsgEventSendFile(path="report.pdf")`
|
||||
2. Matrix-бот читает файл: `/agents/{N}/report.pdf`
|
||||
3. Отправляет как Matrix file message пользователю
|
||||
|
||||
**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
|
||||
|
|
|
|||
340
docs/max-surface-guide.md
Normal file
340
docs/max-surface-guide.md
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
# Руководство по созданию новой поверхности Max
|
||||
|
||||
Этот документ описывает, как написать новую поверхность для Max по образцу текущей Matrix-поверхности в ветке `feat/deploy`.
|
||||
|
||||
Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
|
||||
|
||||
---
|
||||
|
||||
## 1. Общая архитектура
|
||||
|
||||
### 1.1. Что такое поверхность
|
||||
|
||||
Поверхность — это тонкий адаптер между конкретной платформой (Max) и общим ядром бота.
|
||||
|
||||
В репозитории есть разделение:
|
||||
|
||||
- `core/` — общее ядро и бизнес-логика
|
||||
- `adapter/<platform>/` — реализация конкретной поверхности
|
||||
- `sdk/real.py` — работа с реальной платформой / агентом
|
||||
- `config/` — статическая конфигурация агентов
|
||||
- `docs/surface-protocol.md` — общий контракт поверхностей
|
||||
|
||||
### 1.2. Как это работает
|
||||
|
||||
Поверхность должна:
|
||||
|
||||
- принимать нативные события от Max
|
||||
- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
|
||||
- передавать их в `core`
|
||||
- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
|
||||
- преобразовывать ответы обратно в нативные Max-сообщения
|
||||
|
||||
Поверхность не должна:
|
||||
|
||||
- управлять жизненным циклом агентских контейнеров
|
||||
- хранить долгую историю бесед вне `core`/платформы
|
||||
- аутентифицировать пользователей сама (если это не часть Max API)
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура новой поверхности
|
||||
|
||||
### 2.1. Основные каталоги
|
||||
|
||||
Рекомендуемая структура для Max:
|
||||
|
||||
```
|
||||
adapter/max/
|
||||
bot.py
|
||||
converter.py
|
||||
agent_registry.py
|
||||
files.py
|
||||
handlers/
|
||||
store.py
|
||||
```
|
||||
|
||||
### 2.2. Принцип reuse
|
||||
|
||||
По примеру Matrix surface, Max surface должен переиспользовать общий `core` и общий `sdk`.
|
||||
|
||||
Не дублируйте бизнес-логику, а реализуйте только адаптер:
|
||||
|
||||
- `adapter/max/converter.py` — конвертация событий Max ⇄ внутренние структуры
|
||||
- `adapter/max/bot.py` — основной runtime, старт Max client, loop, отправка/прием
|
||||
- `adapter/max/agent_registry.py` — загрузка `config/max-agents.yaml`
|
||||
- `adapter/max/files.py` — хранение входящих/исходящих вложений
|
||||
|
||||
---
|
||||
|
||||
## 3. Контракт входящих/исходящих событий
|
||||
|
||||
### 3.1. Внутренний формат
|
||||
|
||||
Смотрите `core/protocol.py`. Основные типы:
|
||||
|
||||
- `IncomingMessage` — обычное текстовое сообщение + вложения
|
||||
- `IncomingCommand` — управляющая команда
|
||||
- `IncomingCallback` — подтверждение / интерактивные действия
|
||||
- `OutgoingMessage` — ответ пользователю
|
||||
- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.)
|
||||
- `OutgoingTyping` — индикатор печати
|
||||
- `OutgoingNotification` — системное уведомление
|
||||
|
||||
### 3.2. Пример конверсии Matrix
|
||||
|
||||
В Matrix-реализации `adapter/matrix/converter.py`:
|
||||
|
||||
- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel`
|
||||
- `!list`/`!remove` говорят не агенту, а surface-процессу
|
||||
- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment`
|
||||
|
||||
Для Max реализуйте аналогичную логику для native команд вашего клиента.
|
||||
|
||||
---
|
||||
|
||||
## 4. Реестр агентов и маршрутизация
|
||||
|
||||
### 4.1. Что хранит реестр
|
||||
|
||||
В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`.
|
||||
|
||||
Структура:
|
||||
|
||||
```yaml
|
||||
user_agents:
|
||||
"@user0:matrix.example.org": agent-0
|
||||
"@user1:matrix.example.org": agent-1
|
||||
|
||||
agents:
|
||||
- id: agent-0
|
||||
label: "Agent 0"
|
||||
base_url: "http://lambda.coredump.ru:7000/agent_0/"
|
||||
workspace_path: "/agents/0"
|
||||
```
|
||||
|
||||
### 4.2. Логика выбора агента
|
||||
|
||||
- `user_agents` маппит конкретного пользователя на `agent_id`
|
||||
- если user_id не найден, используется первый агент из списка
|
||||
- `agents[].base_url` определяет URL агента
|
||||
- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента
|
||||
|
||||
Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам.
|
||||
|
||||
### 4.3. Рекомендуемая Max-версия
|
||||
|
||||
Создайте `config/max-agents.yaml` с тем же смыслом.
|
||||
|
||||
- `user_agents` — маппинг Max user_id → agent_id
|
||||
- `agents` — список агентов
|
||||
- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0`
|
||||
|
||||
---
|
||||
|
||||
## 5. Файловый контракт
|
||||
|
||||
### 5.1. Shared volume
|
||||
|
||||
Текущее Matrix-решение использует shared volume:
|
||||
|
||||
- surface монтирует общий том как `/agents`
|
||||
- каждый агент видит свою поддиректорию как `/workspace`
|
||||
|
||||
Топология:
|
||||
|
||||
```
|
||||
Bot (/agents) Agent (/workspace = /agents/N/)
|
||||
/agents/0/report.pdf ←──→ /workspace/report.pdf
|
||||
```
|
||||
|
||||
### 5.2. Правила записи файлов
|
||||
|
||||
В `adapter/matrix/files.py` реализовано:
|
||||
|
||||
- входящий файл сохраняется прямо в `{workspace_root}/{filename}`
|
||||
- возвращается путь `workspace_path` относительный внутри рабочего каталога агента
|
||||
- при коллизии имен создаётся `file (1).ext`, `file (2).ext`
|
||||
- `Attachment.workspace_path` передаётся агенту
|
||||
|
||||
Для исходящих файлов:
|
||||
|
||||
- surface читает файл из `workspace_root / workspace_path`
|
||||
- загружает его в платформу
|
||||
|
||||
### 5.3. Пример поведения
|
||||
|
||||
- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace
|
||||
- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path`
|
||||
- Агент пишет результат в `/workspace/result.txt`
|
||||
- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю
|
||||
|
||||
---
|
||||
|
||||
## 6. Чат-менеджмент и контекст
|
||||
|
||||
### 6.1. `platform_chat_id`
|
||||
|
||||
Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента.
|
||||
|
||||
- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py`
|
||||
- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте
|
||||
- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id`
|
||||
|
||||
Для Max surface тот же принцип:
|
||||
|
||||
- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
|
||||
- этот `chat_id` используется для вызовов агента
|
||||
- если в Max есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
|
||||
|
||||
### 6.2. Команды управления чатами
|
||||
|
||||
Matrix поддерживает следующие команды, которые нужно сохранить в Max:
|
||||
|
||||
- `!new [название]` — создать новый чат
|
||||
- `!chats` — список активных чатов
|
||||
- `!rename <название>` — переименовать текущий чат
|
||||
- `!archive` — архивировать чат
|
||||
- `!clear` / `!reset` — сбросить контекст текущего чата
|
||||
- `!yes` / `!no` — подтвердить или отменить действие агента
|
||||
- `!list` — показать очередь вложений
|
||||
- `!remove <n>` / `!remove all` — удалить вложение из очереди
|
||||
- `!help` — справка
|
||||
|
||||
Эти команды реализованы в Matrix через `adapter/matrix/handlers/`.
|
||||
|
||||
### 6.3. Очередь вложений
|
||||
|
||||
Matrix surface поддерживает staged attachments:
|
||||
|
||||
- файл может быть отправлен без текста
|
||||
- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id
|
||||
- следующий текст отправляется агенту вместе со всеми файлами из очереди
|
||||
|
||||
В Max можно реализовать ту же модель:
|
||||
|
||||
- `!list` показывает текущую очередь
|
||||
- `!remove` удаляет файл из очереди
|
||||
- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime и окружение
|
||||
|
||||
### 7.1. Переменные среды
|
||||
|
||||
Для Matrix surface текущий runtime ожидает:
|
||||
|
||||
- `MATRIX_HOMESERVER` — URL Matrix-сервера
|
||||
- `MATRIX_USER_ID` — `@bot:example.org`
|
||||
- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN`
|
||||
- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна
|
||||
- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml`
|
||||
- `AGENT_BASE_URL` — fallback URL агента
|
||||
- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`)
|
||||
|
||||
Для Max surface используйте аналогичные переменные:
|
||||
|
||||
- `MAX_PLATFORM_BACKEND=real`
|
||||
- `MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml`
|
||||
- `SURFACES_WORKSPACE_DIR=/agents`
|
||||
- `AGENT_BASE_URL` — если хотите общий fallback
|
||||
|
||||
### 7.2. Environment contract
|
||||
|
||||
В коде `adapter/matrix/bot.py`:
|
||||
|
||||
- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL`
|
||||
- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH`
|
||||
- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real`
|
||||
|
||||
В Max surface реализуйте ту же логику, заменив префиксы на `MAX_`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Тестирование и валидация
|
||||
|
||||
### 8.1. Юнит-тесты
|
||||
|
||||
В ветке есть покрытие для Matrix surface:
|
||||
|
||||
- `tests/adapter/matrix/test_files.py`
|
||||
- `tests/adapter/matrix/test_dispatcher.py`
|
||||
- `tests/adapter/matrix/test_routed_platform.py`
|
||||
- `tests/adapter/matrix/test_reconciliation.py`
|
||||
- `tests/adapter/matrix/test_context_commands.py`
|
||||
|
||||
Для Max создайте аналогичные тесты:
|
||||
|
||||
- проверка загрузки вложений
|
||||
- проверка маршрутизации по `agent_id`
|
||||
- проверка восстановления `platform_chat_id`
|
||||
- проверка конвертации команд
|
||||
|
||||
### 8.2. Smoke-проверка deployment
|
||||
|
||||
Для Matrix surface есть `docker-compose.prod.yml` и `docker-compose.fullstack.yml`.
|
||||
|
||||
Для Max surface должно быть достаточно:
|
||||
|
||||
- bot-only production deployment
|
||||
- shared volume `/agents`
|
||||
- независимая проверка `config/max-agents.yaml`
|
||||
- проверка, что surface запускается без локального агента
|
||||
|
||||
### 8.3. Проверка контрактов
|
||||
|
||||
Особое внимание:
|
||||
|
||||
- `agent_registry` должен загружать `workspace_path`
|
||||
- file flow должен поддерживать `workspace_path` в `Attachment`
|
||||
- отправка файлов должна использовать `resolve_workspace_attachment_path()`
|
||||
- `platform_chat_id` должен существовать до вызова агента
|
||||
|
||||
---
|
||||
|
||||
## 9. Реализация шаг за шагом
|
||||
|
||||
1. Скопировать `adapter/matrix/` как шаблон для `adapter/max/`.
|
||||
2. Сделать `adapter/max/converter.py`:
|
||||
- превратить native Max-сообщения в `IncomingMessage`
|
||||
- превратить команды в `IncomingCommand`
|
||||
- превратить yes/no-подтверждения в `IncomingCallback`
|
||||
3. Сделать `adapter/max/agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
|
||||
4. Сделать `adapter/max/files.py` на основе `adapter/matrix/files.py`.
|
||||
5. Сделать `adapter/max/bot.py`:
|
||||
- инстанцировать runtime
|
||||
- читать env vars `MAX_*`
|
||||
- загружать реестр агентов
|
||||
- обрабатывать входящие события
|
||||
- отправлять `Outgoing*` обратно в Max
|
||||
6. Реализовать команды управления чатами и очередь вложений.
|
||||
7. Прописать `config/max-agents.yaml`.
|
||||
8. Прописать `docker-compose.max.yml` или аналог, чтобы surface монтировал `/agents`.
|
||||
9. Написать тесты по аналогии с `tests/adapter/matrix/`.
|
||||
10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
|
||||
|
||||
---
|
||||
|
||||
## 10. Важные замечания
|
||||
|
||||
- Текущий Matrix surface на ветке `feat/deploy` — активная реализация, а не устаревший легаси.
|
||||
- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
|
||||
- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
|
||||
- Для Max surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы.
|
||||
- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров.
|
||||
|
||||
---
|
||||
|
||||
## 11. Полезные ссылки внутри репозитория
|
||||
|
||||
- `README.md`
|
||||
- `docs/deploy-architecture.md`
|
||||
- `docs/surface-protocol.md`
|
||||
- `adapter/matrix/bot.py`
|
||||
- `adapter/matrix/converter.py`
|
||||
- `adapter/matrix/agent_registry.py`
|
||||
- `adapter/matrix/files.py`
|
||||
- `adapter/matrix/routed_platform.py`
|
||||
- `adapter/matrix/reconciliation.py`
|
||||
- `tests/adapter/matrix/`
|
||||
|
|
@ -0,0 +1,855 @@
|
|||
# Matrix Multi-Agent Routing And Restart State Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
|
||||
|
||||
**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
|
||||
|
||||
**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `adapter/matrix/agent_registry.py`
|
||||
Purpose: load and validate the YAML agent registry used by Matrix runtime.
|
||||
- Create: `adapter/matrix/routed_platform.py`
|
||||
Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
|
||||
- Create: `adapter/matrix/handlers/agent.py`
|
||||
Purpose: implement `!agent` listing and selection behavior.
|
||||
- Create: `tests/adapter/matrix/test_agent_registry.py`
|
||||
Purpose: cover YAML loading and registry validation.
|
||||
- Create: `tests/adapter/matrix/test_routed_platform.py`
|
||||
Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
|
||||
- Create: `tests/adapter/matrix/test_agent_handler.py`
|
||||
Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
|
||||
- Create: `tests/adapter/matrix/test_restart_persistence.py`
|
||||
Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
|
||||
- Create: `config/matrix-agents.example.yaml`
|
||||
Purpose: document the expected agent registry format.
|
||||
- Modify: `pyproject.toml`
|
||||
Purpose: add YAML parsing dependency required by the runtime registry loader.
|
||||
- Modify: `.env.example`
|
||||
Purpose: document the config path env var for the Matrix agent registry.
|
||||
- Modify: `README.md`
|
||||
Purpose: document the new config file, `!agent`, and restart persistence expectations.
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
|
||||
- Modify: `adapter/matrix/handlers/__init__.py`
|
||||
Purpose: register the new `!agent` command.
|
||||
- Modify: `adapter/matrix/handlers/chat.py`
|
||||
Purpose: require a selected agent for `!new` and bind new rooms to that agent.
|
||||
- Modify: `adapter/matrix/handlers/context_commands.py`
|
||||
Purpose: keep context commands compatible with local chat ids and routed platform delegation.
|
||||
- Modify: `adapter/matrix/handlers/settings.py`
|
||||
Purpose: expose `!agent` in help text.
|
||||
- Modify: `tests/adapter/matrix/test_dispatcher.py`
|
||||
Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
|
||||
- Modify: `tests/adapter/matrix/test_context_commands.py`
|
||||
Purpose: keep load/reset/context flows aligned with the routed platform facade.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add The Agent Registry And Configuration Wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/agent_registry.py`
|
||||
- Create: `tests/adapter/matrix/test_agent_registry.py`
|
||||
- Create: `config/matrix-agents.example.yaml`
|
||||
- Modify: `pyproject.toml`
|
||||
- Modify: `.env.example`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing registry tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_agent_registry.py
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
|
||||
|
||||
|
||||
def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
|
||||
path = tmp_path / "agents.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Analyst\n"
|
||||
" - id: agent-2\n"
|
||||
" label: Research\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registry = load_agent_registry(path)
|
||||
|
||||
assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
|
||||
assert registry.get("agent-1").label == "Analyst"
|
||||
|
||||
|
||||
def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
|
||||
path = tmp_path / "agents.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Analyst\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Duplicate\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(AgentRegistryError, match="duplicate agent id"):
|
||||
load_agent_registry(path)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the registry tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
|
||||
|
||||
Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
|
||||
|
||||
- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
dependencies = [
|
||||
"aiogram>=3.4,<4",
|
||||
"matrix-nio>=0.21",
|
||||
"pydantic>=2.5",
|
||||
"structlog>=24.1",
|
||||
"python-dotenv>=1.0",
|
||||
"httpx>=0.27",
|
||||
"aiohttp>=3.9",
|
||||
"PyYAML>=6.0",
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/agent_registry.py
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class AgentRegistryError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentDefinition:
|
||||
agent_id: str
|
||||
label: str
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
def __init__(self, agents: list[AgentDefinition]) -> None:
|
||||
self.agents = agents
|
||||
self._by_id = {agent.agent_id: agent for agent in agents}
|
||||
|
||||
def get(self, agent_id: str) -> AgentDefinition:
|
||||
try:
|
||||
return self._by_id[agent_id]
|
||||
except KeyError as exc:
|
||||
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
|
||||
|
||||
|
||||
def load_agent_registry(path: str | Path) -> AgentRegistry:
|
||||
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
entries = raw.get("agents")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise AgentRegistryError("agents registry must contain a non-empty agents list")
|
||||
|
||||
agents: list[AgentDefinition] = []
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
agent_id = str(entry.get("id", "")).strip()
|
||||
label = str(entry.get("label", "")).strip()
|
||||
if not agent_id or not label:
|
||||
raise AgentRegistryError("each agent entry requires id and label")
|
||||
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))
|
||||
return AgentRegistry(agents)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the example config and runtime wiring docs**
|
||||
|
||||
```yaml
|
||||
# config/matrix-agents.example.yaml
|
||||
agents:
|
||||
- id: agent-1
|
||||
label: Analyst
|
||||
- id: agent-2
|
||||
label: Research
|
||||
```
|
||||
|
||||
```env
|
||||
# .env.example
|
||||
MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
|
||||
```
|
||||
|
||||
```markdown
|
||||
# README.md
|
||||
1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
|
||||
2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
|
||||
3. Use `!agent` in Matrix to select the active upstream agent
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the registry tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
|
||||
git commit -m "feat: add matrix agent registry loader"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/routed_platform.py`
|
||||
- Create: `tests/adapter/matrix/test_routed_platform.py`
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing routed-platform tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_routed_platform.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.routed_platform import RoutedPlatformClient
|
||||
from adapter.matrix.store import set_room_meta
|
||||
from core.chat import ChatManager
|
||||
from core.store import InMemoryStore
|
||||
from sdk.interface import MessageResponse
|
||||
from sdk.prototype_state import PrototypeStateStore
|
||||
|
||||
|
||||
class FakeDelegate:
|
||||
def __init__(self, agent_id: str) -> None:
|
||||
self.agent_id = agent_id
|
||||
self.calls = []
|
||||
|
||||
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
self.calls.append((user_id, chat_id, text, attachments))
|
||||
return MessageResponse(
|
||||
message_id=user_id,
|
||||
response=f"{self.agent_id}:{text}",
|
||||
tokens_used=0,
|
||||
finished=True,
|
||||
)
|
||||
|
||||
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
|
||||
return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
|
||||
|
||||
async def get_settings(self, user_id: str):
|
||||
return await PrototypeStateStore().get_settings(user_id)
|
||||
|
||||
async def update_settings(self, user_id: str, action):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
|
||||
store = InMemoryStore()
|
||||
chat_mgr = ChatManager(None, store)
|
||||
await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
|
||||
await set_room_meta(
|
||||
store,
|
||||
"!room:example.org",
|
||||
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
|
||||
)
|
||||
|
||||
delegates = {"agent-2": FakeDelegate("agent-2")}
|
||||
platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
|
||||
|
||||
response = await platform.send_message("u1", "C1", "hello")
|
||||
|
||||
assert response.response == "agent-2:hello"
|
||||
assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the routed-platform tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
|
||||
|
||||
Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
|
||||
|
||||
- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
|
||||
|
||||
```python
|
||||
# adapter/matrix/routed_platform.py
|
||||
from __future__ import annotations
|
||||
|
||||
from sdk.interface import PlatformClient
|
||||
|
||||
|
||||
class RoutedPlatformClient(PlatformClient):
|
||||
def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
|
||||
self._store = store
|
||||
self._chat_mgr = chat_mgr
|
||||
self._delegates = delegates
|
||||
|
||||
async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
|
||||
ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
|
||||
if ctx is None:
|
||||
raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
|
||||
room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
|
||||
if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
|
||||
raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
|
||||
delegate = self._delegates[room_meta["agent_id"]]
|
||||
return delegate, str(room_meta["platform_chat_id"])
|
||||
|
||||
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
|
||||
return await delegate.send_message(user_id, platform_chat_id, text, attachments)
|
||||
|
||||
async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
|
||||
async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
|
||||
yield chunk
|
||||
|
||||
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
return await first_delegate.get_or_create_user(external_id, platform, display_name)
|
||||
|
||||
async def get_settings(self, user_id: str):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
return await first_delegate.get_settings(user_id)
|
||||
|
||||
async def update_settings(self, user_id: str, action):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
await first_delegate.update_settings(user_id, action)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
from adapter.matrix.agent_registry import load_agent_registry
|
||||
from adapter.matrix.routed_platform import RoutedPlatformClient
|
||||
|
||||
|
||||
def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
|
||||
backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
|
||||
if backend != "real":
|
||||
return MockPlatformClient()
|
||||
|
||||
registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
|
||||
delegates = {
|
||||
agent.agent_id: RealPlatformClient(
|
||||
agent_id=agent.agent_id,
|
||||
agent_base_url=_agent_base_url_from_env(),
|
||||
prototype_state=PrototypeStateStore(),
|
||||
platform="matrix",
|
||||
)
|
||||
for agent in registry.agents
|
||||
}
|
||||
return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
|
||||
|
||||
|
||||
def build_runtime(...):
|
||||
store = store or InMemoryStore()
|
||||
chat_mgr = ChatManager(None, store)
|
||||
platform = platform or _build_platform_from_env(store, chat_mgr)
|
||||
auth_mgr = AuthManager(platform, store)
|
||||
settings_mgr = SettingsManager(platform, store)
|
||||
dispatcher = EventDispatcher(
|
||||
platform=platform,
|
||||
chat_mgr=chat_mgr,
|
||||
auth_mgr=auth_mgr,
|
||||
settings_mgr=settings_mgr,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the routed-platform tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
|
||||
git commit -m "feat: add matrix routed platform facade"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add `!agent` Selection And Durable User Agent State
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/handlers/agent.py`
|
||||
- Create: `tests/adapter/matrix/test_agent_handler.py`
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
- Modify: `adapter/matrix/handlers/__init__.py`
|
||||
- Modify: `adapter/matrix/handlers/settings.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing agent-handler tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_agent_handler.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.handlers.agent import make_handle_agent
|
||||
from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
|
||||
from core.protocol import IncomingCommand
|
||||
from core.store import InMemoryStore
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.agents = [
|
||||
type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
|
||||
type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_lists_available_agents():
|
||||
handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
|
||||
result = await handler(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert "1. Analyst" in result[0].text
|
||||
assert "2. Research" in result[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
|
||||
store = InMemoryStore()
|
||||
await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
|
||||
handler = make_handle_agent(store=store, registry=FakeRegistry())
|
||||
chat_mgr = type(
|
||||
"ChatMgr",
|
||||
(),
|
||||
{"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
|
||||
)()
|
||||
|
||||
await handler(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
|
||||
None,
|
||||
None,
|
||||
chat_mgr,
|
||||
None,
|
||||
)
|
||||
|
||||
assert await get_selected_agent_id(store, "u1") == "agent-2"
|
||||
room_meta = await get_room_meta(store, "!room:example.org")
|
||||
assert room_meta["agent_id"] == "agent-2"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the agent-handler tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
|
||||
|
||||
Expected: FAIL with missing handler or store helpers.
|
||||
|
||||
- [ ] **Step 3: Add durable store helpers and implement `!agent`**
|
||||
|
||||
```python
|
||||
# adapter/matrix/store.py
|
||||
async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
|
||||
meta = await get_user_meta(store, matrix_user_id) or {}
|
||||
value = meta.get("selected_agent_id")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
|
||||
meta = await get_user_meta(store, matrix_user_id) or {}
|
||||
meta["selected_agent_id"] = agent_id
|
||||
await set_user_meta(store, matrix_user_id, meta)
|
||||
|
||||
|
||||
async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
|
||||
meta = dict(await get_room_meta(store, room_id) or {})
|
||||
meta["agent_id"] = agent_id
|
||||
await set_room_meta(store, room_id, meta)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/agent.py
|
||||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.store import (
|
||||
get_room_meta,
|
||||
get_selected_agent_id,
|
||||
next_platform_chat_id,
|
||||
set_platform_chat_id,
|
||||
set_room_agent_id,
|
||||
set_selected_agent_id,
|
||||
)
|
||||
from core.protocol import IncomingCommand, OutgoingMessage
|
||||
|
||||
|
||||
def make_handle_agent(store, registry):
|
||||
async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
|
||||
if not event.args:
|
||||
current = await get_selected_agent_id(store, event.user_id)
|
||||
lines = ["Доступные агенты:"]
|
||||
for index, agent in enumerate(registry.agents, start=1):
|
||||
marker = " (текущий)" if agent.agent_id == current else ""
|
||||
lines.append(f"{index}. {agent.label}{marker}")
|
||||
lines.append("")
|
||||
lines.append("Выбери агента: !agent <номер>")
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
|
||||
|
||||
agent = registry.agents[int(event.args[0]) - 1]
|
||||
await set_selected_agent_id(store, event.user_id, agent.agent_id)
|
||||
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
|
||||
if ctx is not None:
|
||||
room_meta = await get_room_meta(store, ctx.surface_ref)
|
||||
if room_meta is not None and not room_meta.get("agent_id"):
|
||||
await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
|
||||
if not room_meta.get("platform_chat_id"):
|
||||
await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
|
||||
|
||||
return handle_agent
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the command and update help text**
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/__init__.py
|
||||
from adapter.matrix.handlers.agent import make_handle_agent
|
||||
|
||||
dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/settings.py
|
||||
HELP_TEXT = "\n".join(
|
||||
[
|
||||
"Команды",
|
||||
"",
|
||||
"!agent выбрать активного агента",
|
||||
"!new [название] создать новый чат",
|
||||
"!chats список активных чатов",
|
||||
"!rename <название> переименовать текущий чат",
|
||||
"!archive архивировать текущий чат",
|
||||
"!context показать текущее состояние контекста",
|
||||
"!save [имя] сохранить текущий контекст",
|
||||
"!load показать сохранённые контексты",
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the agent-handler tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
|
||||
git commit -m "feat: add matrix agent selection command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Bind Rooms Correctly And Block Stale Chats
|
||||
|
||||
**Files:**
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
- Modify: `adapter/matrix/handlers/chat.py`
|
||||
- Modify: `adapter/matrix/handlers/context_commands.py`
|
||||
- Modify: `tests/adapter/matrix/test_dispatcher.py`
|
||||
- Modify: `tests/adapter/matrix/test_context_commands.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing dispatcher and context-command tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_dispatcher.py
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||
bot = MatrixBot(client, runtime)
|
||||
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
|
||||
|
||||
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
|
||||
|
||||
client.room_send.assert_awaited_once()
|
||||
assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_chat_requires_selected_agent_and_binds_room_meta():
|
||||
client = SimpleNamespace(
|
||||
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
|
||||
room_put_state=AsyncMock(),
|
||||
)
|
||||
runtime = build_runtime(platform=MockPlatformClient(), client=client)
|
||||
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
|
||||
|
||||
result = await runtime.dispatcher.dispatch(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
|
||||
)
|
||||
|
||||
room_meta = await get_room_meta(runtime.store, "!r2:example")
|
||||
assert room_meta["agent_id"] == "agent-2"
|
||||
assert "Создан чат" in result[0].text
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_context_commands.py
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_selection_calls_platform_with_local_chat_id():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
|
||||
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
|
||||
|
||||
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||
bot = MatrixBot(client, runtime)
|
||||
await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
|
||||
|
||||
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
|
||||
|
||||
platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
|
||||
|
||||
Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
|
||||
|
||||
- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
from adapter.matrix.store import (
|
||||
get_selected_agent_id,
|
||||
get_room_meta,
|
||||
next_platform_chat_id,
|
||||
set_platform_chat_id,
|
||||
set_room_agent_id,
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
|
||||
if not selected_agent_id:
|
||||
return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
|
||||
if room_meta is None:
|
||||
return room_meta, None
|
||||
if not room_meta.get("agent_id"):
|
||||
await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
|
||||
if not room_meta.get("platform_chat_id"):
|
||||
await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
return room_meta, None
|
||||
if room_meta["agent_id"] != selected_agent_id:
|
||||
return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
|
||||
return room_meta, None
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
|
||||
dispatch_chat_id = local_chat_id
|
||||
|
||||
if not body.startswith("!"):
|
||||
room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
|
||||
if blocking is not None:
|
||||
await self._send_all(room.room_id, [blocking])
|
||||
return
|
||||
|
||||
incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/chat.py
|
||||
from adapter.matrix.store import get_selected_agent_id
|
||||
|
||||
selected_agent_id = await get_selected_agent_id(store, event.user_id)
|
||||
if not selected_agent_id:
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
|
||||
|
||||
await set_room_meta(
|
||||
store,
|
||||
room_id,
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": chat_id,
|
||||
"display_name": room_name,
|
||||
"matrix_user_id": event.user_id,
|
||||
"space_id": space_id,
|
||||
"platform_chat_id": platform_chat_id,
|
||||
"agent_id": selected_agent_id,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
|
||||
|
||||
await self.runtime.platform.send_message(
|
||||
user_id,
|
||||
local_chat_id,
|
||||
LOAD_PROMPT.format(name=name),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
|
||||
git commit -m "feat: bind matrix rooms to selected agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Prove Durable Restart State And Sequence Persistence
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/adapter/matrix/test_restart_persistence.py`
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing restart-persistence tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_restart_persistence.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.store import (
|
||||
get_selected_agent_id,
|
||||
next_platform_chat_id,
|
||||
set_room_meta,
|
||||
set_selected_agent_id,
|
||||
)
|
||||
from core.store import SQLiteStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
|
||||
db_path = tmp_path / "matrix.db"
|
||||
store = SQLiteStore(str(db_path))
|
||||
await set_selected_agent_id(store, "u1", "agent-2")
|
||||
await set_room_meta(
|
||||
store,
|
||||
"!room:example.org",
|
||||
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
|
||||
)
|
||||
|
||||
reopened = SQLiteStore(str(db_path))
|
||||
assert await get_selected_agent_id(reopened, "u1") == "agent-2"
|
||||
assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
|
||||
assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
|
||||
db_path = tmp_path / "matrix.db"
|
||||
store = SQLiteStore(str(db_path))
|
||||
|
||||
assert await next_platform_chat_id(store) == "1"
|
||||
assert await next_platform_chat_id(store) == "2"
|
||||
|
||||
reopened = SQLiteStore(str(db_path))
|
||||
assert await next_platform_chat_id(reopened) == "3"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
|
||||
|
||||
Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
|
||||
|
||||
- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
|
||||
|
||||
```python
|
||||
# adapter/matrix/store.py
|
||||
PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
|
||||
|
||||
|
||||
async def next_platform_chat_id(store: StateStore) -> str:
|
||||
async with _PLATFORM_CHAT_SEQ_LOCK:
|
||||
data = await store.get(PLATFORM_CHAT_SEQ_KEY)
|
||||
index = int((data or {}).get("next_platform_chat_index", 1))
|
||||
await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
|
||||
return str(index)
|
||||
```
|
||||
|
||||
```markdown
|
||||
# README.md
|
||||
- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
|
||||
- normal restart is supported only when those paths survive container recreation
|
||||
- staged attachments and pending confirmations are intentionally not restored
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the combined verification sweep**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
|
||||
git commit -m "test: cover matrix restart state persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- Multi-agent agent registry: Task 1
|
||||
- Shared `PlatformClient` preserved via routing facade: Task 2
|
||||
- `!agent` UX and durable `selected_agent_id`: Task 3
|
||||
- Unbound room activation, `!new`, stale room rejection: Task 4
|
||||
- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- No `TODO`, `TBD`, or “implement later” markers remain.
|
||||
- Each task includes exact file paths, tests, commands, and minimal code snippets.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `selected_agent_id` lives in user metadata throughout the plan.
|
||||
- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
|
||||
- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.
|
||||
Loading…
Add table
Add a link
Reference in a new issue